From bee902b900fc7d8bff03ed8b4a1a6c092b2e890a Mon Sep 17 00:00:00 2001 From: harshi922 Date: Wed, 11 Feb 2026 11:19:59 -0500 Subject: [PATCH 01/17] central http client module with async detect --- polyapi/http_client.py | 77 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 polyapi/http_client.py diff --git a/polyapi/http_client.py b/polyapi/http_client.py new file mode 100644 index 0000000..15efbce --- /dev/null +++ b/polyapi/http_client.py @@ -0,0 +1,77 @@ +import asyncio +import httpx + +_sync_client: httpx.Client | None = None +_async_client: httpx.AsyncClient | None = None + + +def is_async() -> bool: + try: + asyncio.get_running_loop() + return True + except RuntimeError: + return False + + +def _get_sync_client() -> httpx.Client: + global _sync_client + if _sync_client is None: + _sync_client = httpx.Client(verify=False) + return _sync_client + + +def _get_async_client() -> httpx.AsyncClient: + global _async_client + if _async_client is None: + _async_client = httpx.AsyncClient(verify=False) + return _async_client + + +def post(url, **kwargs) -> httpx.Response: + return _get_sync_client().post(url, **kwargs) + + +async def async_post(url, **kwargs) -> httpx.Response: + return await _get_async_client().post(url, **kwargs) + + +def get(url, **kwargs) -> httpx.Response: + return _get_sync_client().get(url, **kwargs) + + +async def async_get(url, **kwargs) -> httpx.Response: + return await _get_async_client().get(url, **kwargs) + + +def patch(url, **kwargs) -> httpx.Response: + return _get_sync_client().patch(url, **kwargs) + + +async def async_patch(url, **kwargs) -> httpx.Response: + return await _get_async_client().patch(url, **kwargs) + + +def delete(url, **kwargs) -> httpx.Response: + return _get_sync_client().delete(url, **kwargs) + + +async def async_delete(url, **kwargs) -> httpx.Response: + return await _get_async_client().delete(url, **kwargs) + + +def request(method, url, **kwargs) -> httpx.Response: + return _get_sync_client().request(method, url, **kwargs) + + +async def async_request(method, url, **kwargs) -> httpx.Response: + return await _get_async_client().request(method, url, **kwargs) + + +def close(): + global _sync_client, _async_client + if _sync_client is not None: + _sync_client.close() + _sync_client = None + if _async_client is not None: + asyncio.get_event_loop().run_until_complete(_async_client.aclose()) + _async_client = None From 6d367c67c342c81e78c8d700ea8a3cd3e78e7919 Mon Sep 17 00:00:00 2001 From: harshi922 Date: Wed, 11 Feb 2026 12:17:22 -0500 Subject: [PATCH 02/17] fix main point execute --- polyapi/execute.py | 203 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 158 insertions(+), 45 deletions(-) diff --git a/polyapi/execute.py b/polyapi/execute.py index 5d75048..33546bf 100644 --- a/polyapi/execute.py +++ b/polyapi/execute.py @@ -1,74 +1,134 @@ -from typing import Dict, Optional -import requests +from typing import Union +from collections.abc import Coroutine +import httpx import os import logging -from requests import Response from polyapi.config import get_api_key_and_url, get_mtls_config from polyapi.exceptions import PolyApiException +from polyapi import http_client logger = logging.getLogger("poly") -def direct_execute(function_type, function_id, data) -> Response: - """ execute a specific function id/type - """ - api_key, api_url = get_api_key_and_url() - headers = {"Authorization": f"Bearer {api_key}"} - url = f"{api_url}/functions/{function_type}/{function_id}/direct-execute" - - endpoint_info = requests.post(url, json=data, headers=headers) - if endpoint_info.status_code < 200 or endpoint_info.status_code >= 300: - error_content = endpoint_info.content.decode("utf-8", errors="ignore") + +def _check_endpoint_error(resp, function_type, function_id, data): + if resp.status_code < 200 or resp.status_code >= 300: + error_content = resp.content.decode("utf-8", errors="ignore") if function_type == 'api' and os.getenv("LOGS_ENABLED"): - raise PolyApiException(f"Error executing api function with id: {function_id}. Status code: {endpoint_info.status_code}. Request data: {data}, Response: {error_content}") + raise PolyApiException(f"Error executing api function with id: {function_id}. Status code: {resp.status_code}. Request data: {data}, Response: {error_content}") elif function_type != 'api': - raise PolyApiException(f"{endpoint_info.status_code}: {error_content}") - - endpoint_info_data = endpoint_info.json() + raise PolyApiException(f"{resp.status_code}: {error_content}") + + +def _check_response_error(resp, function_type, function_id, data): + if resp.status_code < 200 or resp.status_code >= 300: + error_content = resp.content.decode("utf-8", errors="ignore") + if function_type == 'api' and os.getenv("LOGS_ENABLED"): + logger.error(f"Error executing api function with id: {function_id}. Status code: {resp.status_code}. Request data: {data}, Response: {error_content}") + elif function_type != 'api': + raise PolyApiException(f"{resp.status_code}: {error_content}") + + +def _build_direct_execute_params(endpoint_info_data): request_params = endpoint_info_data.copy() request_params.pop("url", None) - if "maxRedirects" in request_params: - request_params["allow_redirects"] = request_params.pop("maxRedirects") > 0 - + request_params["follow_redirects"] = request_params.pop("maxRedirects") > 0 + return request_params + + +def _sync_direct_execute(function_type, function_id, data) -> httpx.Response: + api_key, api_url = get_api_key_and_url() + headers = {"Authorization": f"Bearer {api_key}"} + url = f"{api_url}/functions/{function_type}/{function_id}/direct-execute" + + endpoint_info = http_client.post(url, json=data, headers=headers) + _check_endpoint_error(endpoint_info, function_type, function_id, data) + + endpoint_info_data = endpoint_info.json() + request_params = _build_direct_execute_params(endpoint_info_data) + has_mtls, cert_path, key_path, ca_path = get_mtls_config() - + if has_mtls: - resp = requests.request( + resp = http_client.request( url=endpoint_info_data["url"], cert=(cert_path, key_path), verify=ca_path, **request_params ) else: - resp = requests.request( + resp = http_client.request( url=endpoint_info_data["url"], verify=False, **request_params ) - if (resp.status_code < 200 or resp.status_code >= 300): - error_content = resp.content.decode("utf-8", errors="ignore") - if function_type == 'api' and os.getenv("LOGS_ENABLED"): - logger.error(f"Error executing api function with id: {function_id}. Status code: {resp.status_code}. Request data: {data}, Response: {error_content}") - elif function_type != 'api': - raise PolyApiException(f"{resp.status_code}: {error_content}") - + _check_response_error(resp, function_type, function_id, data) return resp -def execute(function_type, function_id, data) -> Response: + +async def _async_direct_execute(function_type, function_id, data) -> httpx.Response: + api_key, api_url = get_api_key_and_url() + headers = {"Authorization": f"Bearer {api_key}"} + url = f"{api_url}/functions/{function_type}/{function_id}/direct-execute" + + endpoint_info = await http_client.async_post(url, json=data, headers=headers) + _check_endpoint_error(endpoint_info, function_type, function_id, data) + + endpoint_info_data = endpoint_info.json() + request_params = _build_direct_execute_params(endpoint_info_data) + + has_mtls, cert_path, key_path, ca_path = get_mtls_config() + + if has_mtls: + resp = await http_client.async_request( + url=endpoint_info_data["url"], + cert=(cert_path, key_path), + verify=ca_path, + **request_params + ) + else: + resp = await http_client.async_request( + url=endpoint_info_data["url"], + verify=False, + **request_params + ) + + _check_response_error(resp, function_type, function_id, data) + return resp + + +def direct_execute(function_type, function_id, data) -> Union[httpx.Response, Coroutine]: """ execute a specific function id/type """ + if http_client.is_async(): + return _async_direct_execute(function_type, function_id, data) + return _sync_direct_execute(function_type, function_id, data) + + +def _sync_execute(function_type, function_id, data) -> httpx.Response: api_key, api_url = get_api_key_and_url() headers = {"Authorization": f"Bearer {api_key}"} + url = f"{api_url}/functions/{function_type}/{function_id}/execute" + + resp = http_client.post(url, json=data, headers=headers) + + if (resp.status_code < 200 or resp.status_code >= 300) and os.getenv("LOGS_ENABLED"): + error_content = resp.content.decode("utf-8", errors="ignore") + if function_type == 'api' and os.getenv("LOGS_ENABLED"): + logger.error(f"Error executing api function with id: {function_id}. Status code: {resp.status_code}. Request data: {data}, Response: {error_content}") + elif function_type != 'api': + raise PolyApiException(f"{resp.status_code}: {error_content}") + return resp + + +async def _async_execute(function_type, function_id, data) -> httpx.Response: + api_key, api_url = get_api_key_and_url() + headers = {"Authorization": f"Bearer {api_key}"} url = f"{api_url}/functions/{function_type}/{function_id}/execute" - - # Make the request - resp = requests.post( - url, - json=data, - headers=headers, - ) + + resp = await http_client.async_post(url, json=data, headers=headers) if (resp.status_code < 200 or resp.status_code >= 300) and os.getenv("LOGS_ENABLED"): error_content = resp.content.decode("utf-8", errors="ignore") @@ -80,30 +140,83 @@ def execute(function_type, function_id, data) -> Response: return resp -def execute_post(path, data): +def execute(function_type, function_id, data) -> Union[httpx.Response, Coroutine]: + """ execute a specific function id/type + """ + if http_client.is_async(): + return _async_execute(function_type, function_id, data) + return _sync_execute(function_type, function_id, data) + + +def _sync_execute_post(path, data): api_key, api_url = get_api_key_and_url() headers = {"Authorization": f"Bearer {api_key}"} - resp = requests.post(api_url + path, json=data, headers=headers) + return http_client.post(api_url + path, json=data, headers=headers) + + +async def _async_execute_post(path, data): + api_key, api_url = get_api_key_and_url() + headers = {"Authorization": f"Bearer {api_key}"} + return await http_client.async_post(api_url + path, json=data, headers=headers) + + +def execute_post(path, data): + if http_client.is_async(): + return _async_execute_post(path, data) + return _sync_execute_post(path, data) + + +def _sync_variable_get(variable_id: str) -> httpx.Response: + api_key, base_url = get_api_key_and_url() + headers = {"Authorization": f"Bearer {api_key}"} + url = f"{base_url}/variables/{variable_id}/value" + resp = http_client.get(url, headers=headers) + if resp.status_code != 200 and resp.status_code != 201: + error_content = resp.content.decode("utf-8", errors="ignore") + raise PolyApiException(f"{resp.status_code}: {error_content}") return resp -def variable_get(variable_id: str) -> Response: +async def _async_variable_get(variable_id: str) -> httpx.Response: api_key, base_url = get_api_key_and_url() headers = {"Authorization": f"Bearer {api_key}"} url = f"{base_url}/variables/{variable_id}/value" - resp = requests.get(url, headers=headers) + resp = await http_client.async_get(url, headers=headers) + if resp.status_code != 200 and resp.status_code != 201: + error_content = resp.content.decode("utf-8", errors="ignore") + raise PolyApiException(f"{resp.status_code}: {error_content}") + return resp + + +def variable_get(variable_id: str) -> Union[httpx.Response, Coroutine]: + if http_client.is_async(): + return _async_variable_get(variable_id) + return _sync_variable_get(variable_id) + + +def _sync_variable_update(variable_id: str, value) -> httpx.Response: + api_key, base_url = get_api_key_and_url() + headers = {"Authorization": f"Bearer {api_key}"} + url = f"{base_url}/variables/{variable_id}" + resp = http_client.patch(url, data={"value": value}, headers=headers) if resp.status_code != 200 and resp.status_code != 201: error_content = resp.content.decode("utf-8", errors="ignore") raise PolyApiException(f"{resp.status_code}: {error_content}") return resp -def variable_update(variable_id: str, value) -> Response: +async def _async_variable_update(variable_id: str, value) -> httpx.Response: api_key, base_url = get_api_key_and_url() headers = {"Authorization": f"Bearer {api_key}"} url = f"{base_url}/variables/{variable_id}" - resp = requests.patch(url, data={"value": value}, headers=headers) + resp = await http_client.async_patch(url, data={"value": value}, headers=headers) if resp.status_code != 200 and resp.status_code != 201: error_content = resp.content.decode("utf-8", errors="ignore") raise PolyApiException(f"{resp.status_code}: {error_content}") - return resp \ No newline at end of file + return resp + + +def variable_update(variable_id: str, value) -> Union[httpx.Response, Coroutine]: + if http_client.is_async(): + return _async_variable_update(variable_id, value) + return _sync_variable_update(variable_id, value) From 4eca1605778886811f68011fb55c646405984786 Mon Sep 17 00:00:00 2001 From: harshi922 Date: Wed, 11 Feb 2026 12:18:22 -0500 Subject: [PATCH 03/17] none the timeout since sleepysfx --- polyapi/http_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/polyapi/http_client.py b/polyapi/http_client.py index 15efbce..217a23f 100644 --- a/polyapi/http_client.py +++ b/polyapi/http_client.py @@ -16,14 +16,14 @@ def is_async() -> bool: def _get_sync_client() -> httpx.Client: global _sync_client if _sync_client is None: - _sync_client = httpx.Client(verify=False) + _sync_client = httpx.Client(verify=False, timeout=None) return _sync_client def _get_async_client() -> httpx.AsyncClient: global _async_client if _async_client is None: - _async_client = httpx.AsyncClient(verify=False) + _async_client = httpx.AsyncClient(verify=False, timeout=None) return _async_client From ca5914271981cc400ec3a231f9b9a42c29ab9031 Mon Sep 17 00:00:00 2001 From: harshi922 Date: Wed, 11 Feb 2026 12:19:21 -0500 Subject: [PATCH 04/17] sync simple replace --- polyapi/sync.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/polyapi/sync.py b/polyapi/sync.py index 850538b..2b82c45 100644 --- a/polyapi/sync.py +++ b/polyapi/sync.py @@ -2,8 +2,7 @@ from datetime import datetime from typing import List, Dict from typing_extensions import cast # type: ignore -import requests - +from polyapi import http_client from polyapi.utils import get_auth_headers from polyapi.config import get_api_key_and_url from polyapi.parser import get_jsonschema_type @@ -35,10 +34,10 @@ def remove_deployable_function(deployable: SyncDeployment) -> bool: raise Exception("Missing api key!") headers = get_auth_headers(api_key) url = f'{deployable["instance"]}/functions/{deployable["type"].replace("-function", "")}/{deployable["id"]}' - response = requests.get(url, headers=headers) + response = http_client.get(url, headers=headers) if response.status_code != 200: return False - requests.delete(url, headers=headers) + http_client.delete(url, headers=headers) return True def remove_deployable(deployable: SyncDeployment) -> bool: @@ -65,7 +64,7 @@ def sync_function_and_get_id(deployable: SyncDeployment, code: str) -> str: "returnTypeSchema": deployable["types"]["returns"]["typeSchema"], "arguments": [{**p, "key": p["name"], "type": get_jsonschema_type(p["type"]) } for p in deployable["types"]["params"]], } - response = requests.post(url, headers=headers, json=payload) + response = http_client.post(url, headers=headers, json=payload) response.raise_for_status() return response.json()['id'] From 310184b6a903c3c10d9a90c6cdfd17a4a9083242 Mon Sep 17 00:00:00 2001 From: harshi922 Date: Wed, 11 Feb 2026 12:20:36 -0500 Subject: [PATCH 05/17] more replaces simple --- polyapi/function_cli.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/polyapi/function_cli.py b/polyapi/function_cli.py index 219bbb0..2ce79a8 100644 --- a/polyapi/function_cli.py +++ b/polyapi/function_cli.py @@ -1,7 +1,6 @@ import sys from typing import Any, List, Optional -import requests - +from polyapi import http_client from polyapi.config import get_api_key_and_url from polyapi.utils import get_auth_headers, print_green, print_red, print_yellow from polyapi.parser import parse_function_code, get_jsonschema_type @@ -87,7 +86,7 @@ def function_add_or_update( sys.exit(1) headers = get_auth_headers(api_key) - resp = requests.post(url, headers=headers, json=data) + resp = http_client.post(url, headers=headers, json=data) if resp.status_code in [200, 201]: print_green("DEPLOYED") function_id = resp.json()["id"] @@ -126,5 +125,5 @@ def spec_delete(function_type: str, function_id: str): print(f"Unknown function type: {function_type}") sys.exit(1) headers = get_auth_headers(api_key) - resp = requests.delete(url, headers=headers) + resp = http_client.delete(url, headers=headers) return resp \ No newline at end of file From abc510584aeabec8c72db860fbbee508b1b11b37 Mon Sep 17 00:00:00 2001 From: harshi922 Date: Wed, 11 Feb 2026 12:22:27 -0500 Subject: [PATCH 06/17] last few replaces asynced reqs --- polyapi/generate.py | 4 ++-- polyapi/poly_tables.py | 4 ++-- polyapi/prepare.py | 7 +++---- polyapi/rendered_spec.py | 6 +++--- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/polyapi/generate.py b/polyapi/generate.py index 00366c9..883f0ca 100644 --- a/polyapi/generate.py +++ b/polyapi/generate.py @@ -1,5 +1,4 @@ import json -import requests import os import uuid import shutil @@ -20,6 +19,7 @@ from .utils import add_import_to_init, get_auth_headers, init_the_init, print_green, to_func_namespace from .variables import generate_variables from .poly_tables import generate_tables +from . import http_client from .config import get_api_key_and_url, get_direct_execute_config, get_cached_generate_args SUPPORTED_FUNCTION_TYPES = { @@ -62,7 +62,7 @@ def get_specs(contexts: Optional[List[str]] = None, names: Optional[List[str]] = if get_direct_execute_config(): params["apiFunctionDirectExecute"] = "true" - resp = requests.get(url, headers=headers, params=params) + resp = http_client.get(url, headers=headers, params=params) if resp.status_code == 200: return resp.json() else: diff --git a/polyapi/poly_tables.py b/polyapi/poly_tables.py index 4c00a72..1f46c2a 100644 --- a/polyapi/poly_tables.py +++ b/polyapi/poly_tables.py @@ -1,5 +1,5 @@ import os -import requests +from polyapi import http_client from typing_extensions import NotRequired, TypedDict from typing import List, Union, Type, Dict, Any, Literal, Tuple, Optional, get_args, get_origin from polyapi.utils import add_import_to_init, init_the_init @@ -43,7 +43,7 @@ def execute_query(table_id, method, query): headers = {{ 'x-poly-execution-id': polyCustom.get('executionId') }} - response = requests.post(url, json=query, headers=headers) + response = http_client.post(url, json=query, headers=headers) response.raise_for_status() return response.json() except Exception as e: diff --git a/polyapi/prepare.py b/polyapi/prepare.py index b1580e2..b13b337 100644 --- a/polyapi/prepare.py +++ b/polyapi/prepare.py @@ -2,8 +2,7 @@ import sys import subprocess from typing import List, Tuple, Literal -import requests - +from polyapi import http_client from polyapi.utils import get_auth_headers from polyapi.config import get_api_key_and_url from polyapi.parser import parse_function_code @@ -32,7 +31,7 @@ def get_server_function_description(description: str, arguments, code: str) -> s api_key, api_url = get_api_key_and_url() headers = get_auth_headers(api_key) data = {"description": description, "arguments": arguments, "code": code} - response = requests.post(f"{api_url}/functions/server/description-generation", headers=headers, json=data) + response = http_client.post(f"{api_url}/functions/server/description-generation", headers=headers, json=data) return response.json() def get_client_function_description(description: str, arguments, code: str) -> str: @@ -40,7 +39,7 @@ def get_client_function_description(description: str, arguments, code: str) -> s headers = get_auth_headers(api_key) # Simulated API call to generate client function descriptions data = {"description": description, "arguments": arguments, "code": code} - response = requests.post(f"{api_url}/functions/client/description-generation", headers=headers, json=data) + response = http_client.post(f"{api_url}/functions/client/description-generation", headers=headers, json=data) return response.json() def fill_in_missing_function_details(deployable: DeployableRecord, code: str) -> DeployableRecord: diff --git a/polyapi/rendered_spec.py b/polyapi/rendered_spec.py index 2206417..e746a56 100644 --- a/polyapi/rendered_spec.py +++ b/polyapi/rendered_spec.py @@ -1,7 +1,7 @@ import os from typing import Optional -import requests +from polyapi import http_client from polyapi.config import get_api_key_and_url from polyapi.generate import read_cached_specs, render_spec from polyapi.typedefs import SpecificationDto @@ -31,7 +31,7 @@ def update_rendered_spec(spec: SpecificationDto): url = f"{base_url}/functions/rendered-specs" headers = {"Authorization": f"Bearer {api_key}"} - resp = requests.post(url, json=data, headers=headers) + resp = http_client.post(url, json=data, headers=headers) assert resp.status_code == 201, (resp.text, resp.status_code) @@ -40,7 +40,7 @@ def _get_spec(spec_id: str, no_types: bool = False) -> Optional[SpecificationDto url = f"{base_url}/specs" headers = {"Authorization": f"Bearer {api_key}"} params = {"noTypes": str(no_types).lower()} - resp = requests.get(url, headers=headers, params=params) + resp = http_client.get(url, headers=headers, params=params) if resp.status_code == 200: specs = resp.json() for spec in specs: From d88f3b8f29f8c738db36f0624caa54b857b2ff4c Mon Sep 17 00:00:00 2001 From: harshi922 Date: Wed, 11 Feb 2026 12:34:01 -0500 Subject: [PATCH 07/17] testing async calls feature --- tests/test_async_proof.py | 380 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 tests/test_async_proof.py diff --git a/tests/test_async_proof.py b/tests/test_async_proof.py new file mode 100644 index 0000000..805c553 --- /dev/null +++ b/tests/test_async_proof.py @@ -0,0 +1,380 @@ +"""Tests proving the sync/async dual-mode pattern works correctly. + +These tests mock HTTP calls so no live server is needed. They verify that: +1. is_async() correctly detects sync vs async context +2. http_client uses sync Client in sync context, AsyncClient in async context +3. execute(), direct_execute(), execute_post(), variable_get(), variable_update() + all return the right type based on calling context +4. Parallel async execution with asyncio.gather works +""" + +import asyncio +import inspect +from collections.abc import Coroutine +from unittest.mock import patch, MagicMock, AsyncMock + +import httpx +import pytest + +from polyapi import http_client +from polyapi.execute import ( + execute, + direct_execute, + execute_post, + variable_get, + variable_update, + _build_direct_execute_params, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _fake_response(status_code=200, json_data=None, text="ok"): + """Build a fake httpx.Response.""" + resp = MagicMock(spec=httpx.Response) + resp.status_code = status_code + resp.text = text + resp.content = text.encode() + resp.json.return_value = json_data or {} + return resp + + +# 1. is_async() detection + +class TestIsAsync: + def test_sync_context_returns_false(self): + assert http_client.is_async() is False + + def test_async_context_returns_true(self): + async def _check(): + return http_client.is_async() + + result = asyncio.run(_check()) + assert result is True + + def test_nested_sync_inside_async_still_true(self): + """A plain (non-async) helper called from within an event loop + should still report True because the loop is running.""" + + def sync_helper(): + return http_client.is_async() + + async def _check(): + return sync_helper() + + assert asyncio.run(_check()) is True + + +# 2. http_client sync / async client pairing + +class TestHttpClientPairing: + """Verify that the sync helpers call httpx.Client and the async helpers + call httpx.AsyncClient.""" + + def setup_method(self): + # Reset singletons so each test starts fresh + http_client._sync_client = None + http_client._async_client = None + + def teardown_method(self): + http_client._sync_client = None + http_client._async_client = None + + @patch.object(httpx.Client, "post", return_value=_fake_response()) + def test_sync_post_uses_sync_client(self, mock_post): + resp = http_client.post("https://example.com", json={}) + mock_post.assert_called_once() + assert resp.status_code == 200 + # The sync client should have been created + assert http_client._sync_client is not None + assert http_client._async_client is None + + @patch.object(httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=_fake_response()) + def test_async_post_uses_async_client(self, mock_post): + async def _run(): + return await http_client.async_post("https://example.com", json={}) + + resp = asyncio.run(_run()) + mock_post.assert_called_once() + assert resp.status_code == 200 + assert http_client._async_client is not None + + @patch.object(httpx.Client, "get", return_value=_fake_response()) + def test_sync_get(self, mock_get): + resp = http_client.get("https://example.com") + mock_get.assert_called_once() + assert resp.status_code == 200 + + @patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock, return_value=_fake_response()) + def test_async_get(self, mock_get): + async def _run(): + return await http_client.async_get("https://example.com") + + resp = asyncio.run(_run()) + mock_get.assert_called_once() + assert resp.status_code == 200 + + @patch.object(httpx.Client, "patch", return_value=_fake_response()) + def test_sync_patch(self, mock_patch_method): + resp = http_client.patch("https://example.com", data={"v": 1}) + mock_patch_method.assert_called_once() + assert resp.status_code == 200 + + @patch.object(httpx.AsyncClient, "patch", new_callable=AsyncMock, return_value=_fake_response()) + def test_async_patch(self, mock_patch_method): + async def _run(): + return await http_client.async_patch("https://example.com", data={"v": 1}) + + resp = asyncio.run(_run()) + mock_patch_method.assert_called_once() + + @patch.object(httpx.Client, "delete", return_value=_fake_response()) + def test_sync_delete(self, mock_delete): + resp = http_client.delete("https://example.com") + mock_delete.assert_called_once() + + @patch.object(httpx.AsyncClient, "delete", new_callable=AsyncMock, return_value=_fake_response()) + def test_async_delete(self, mock_delete): + async def _run(): + return await http_client.async_delete("https://example.com") + + resp = asyncio.run(_run()) + mock_delete.assert_called_once() + + @patch.object(httpx.Client, "request", return_value=_fake_response()) + def test_sync_request(self, mock_request): + resp = http_client.request("POST", "https://example.com") + mock_request.assert_called_once() + + @patch.object(httpx.AsyncClient, "request", new_callable=AsyncMock, return_value=_fake_response()) + def test_async_request(self, mock_request): + async def _run(): + return await http_client.async_request("POST", "https://example.com") + + resp = asyncio.run(_run()) + mock_request.assert_called_once() + + +# 3. execute() dual-mode + +_CONFIG_PATCH = patch( + "polyapi.execute.get_api_key_and_url", + return_value=("fake-key", "https://api.example.com"), +) +_MTLS_PATCH = patch( + "polyapi.execute.get_mtls_config", + return_value=(False, None, None, None), +) + + +class TestExecuteDualMode: + """execute() returns httpx.Response in sync context, coroutine in async.""" + + @_CONFIG_PATCH + @patch("polyapi.http_client.post", return_value=_fake_response(200, text='"hello"')) + def test_sync_returns_response(self, mock_post, _mock_config): + result = execute("server", "some-id", {}) + assert isinstance(result, MagicMock) # our fake Response + assert result.status_code == 200 + mock_post.assert_called_once() + + @_CONFIG_PATCH + @patch("polyapi.http_client.async_post", new_callable=AsyncMock, return_value=_fake_response(200, text='"hello"')) + def test_async_returns_coroutine_then_response(self, mock_post, _mock_config): + async def _run(): + coro = execute("server", "some-id", {}) + # In async context, execute() returns a coroutine + assert inspect.isawaitable(coro) + return await coro + + result = asyncio.run(_run()) + assert result.status_code == 200 + mock_post.assert_called_once() + + @_CONFIG_PATCH + @patch("polyapi.http_client.post", return_value=_fake_response(200, text='"hello"')) + def test_sync_calls_correct_url(self, mock_post, _mock_config): + execute("server", "abc-123", {"arg": 1}) + call_args = mock_post.call_args + assert "/functions/server/abc-123/execute" in call_args[0][0] + assert call_args[1]["json"] == {"arg": 1} + assert "Bearer fake-key" in call_args[1]["headers"]["Authorization"] + + +# 4. direct_execute() dual-mode + +class TestDirectExecuteDualMode: + + @_CONFIG_PATCH + @_MTLS_PATCH + @patch("polyapi.http_client.request", return_value=_fake_response(200, text='{"result": 1}')) + @patch("polyapi.http_client.post", return_value=_fake_response( + 200, json_data={"url": "https://target.example.com", "method": "GET"}, + )) + def test_sync_returns_response(self, mock_post, mock_request, _mtls, _config): + result = direct_execute("server", "fn-id", {}) + assert result.status_code == 200 + # First call: POST to /direct-execute endpoint info + assert "/direct-execute" in mock_post.call_args[0][0] + # Second call: actual request to the target URL + mock_request.assert_called_once() + + @_CONFIG_PATCH + @_MTLS_PATCH + @patch("polyapi.http_client.async_request", new_callable=AsyncMock, return_value=_fake_response(200)) + @patch("polyapi.http_client.async_post", new_callable=AsyncMock, return_value=_fake_response( + 200, json_data={"url": "https://target.example.com", "method": "GET"}, + )) + def test_async_returns_coroutine(self, mock_post, mock_request, _mtls, _config): + async def _run(): + coro = direct_execute("server", "fn-id", {}) + assert inspect.isawaitable(coro) + return await coro + + result = asyncio.run(_run()) + assert result.status_code == 200 + + +# 5. execute_post() dual-mode + +class TestExecutePostDualMode: + + @_CONFIG_PATCH + @patch("polyapi.http_client.post", return_value=_fake_response()) + def test_sync(self, mock_post, _config): + result = execute_post("/some/path", {"data": 1}) + assert result.status_code == 200 + assert "https://api.example.com/some/path" == mock_post.call_args[0][0] + + @_CONFIG_PATCH + @patch("polyapi.http_client.async_post", new_callable=AsyncMock, return_value=_fake_response()) + def test_async(self, mock_post, _config): + async def _run(): + coro = execute_post("/some/path", {"data": 1}) + assert inspect.isawaitable(coro) + return await coro + + result = asyncio.run(_run()) + assert result.status_code == 200 + + +# 6. variable_get / variable_update dual-mode + +class TestVariableGetDualMode: + + @_CONFIG_PATCH + @patch("polyapi.http_client.get", return_value=_fake_response(200, text="42")) + def test_sync(self, mock_get, _config): + result = variable_get("var-123") + assert result.status_code == 200 + assert "/variables/var-123/value" in mock_get.call_args[0][0] + + @_CONFIG_PATCH + @patch("polyapi.http_client.async_get", new_callable=AsyncMock, return_value=_fake_response(200, text="42")) + def test_async(self, mock_get, _config): + async def _run(): + coro = variable_get("var-123") + assert inspect.isawaitable(coro) + return await coro + + result = asyncio.run(_run()) + assert result.status_code == 200 + + +class TestVariableUpdateDualMode: + + @_CONFIG_PATCH + @patch("polyapi.http_client.patch", return_value=_fake_response(200)) + def test_sync(self, mock_patch_call, _config): + result = variable_update("var-123", "new-value") + assert result.status_code == 200 + + @_CONFIG_PATCH + @patch("polyapi.http_client.async_patch", new_callable=AsyncMock, return_value=_fake_response(200)) + def test_async(self, mock_patch_call, _config): + async def _run(): + coro = variable_update("var-123", "new-value") + assert inspect.isawaitable(coro) + return await coro + + result = asyncio.run(_run()) + assert result.status_code == 200 + + +# 7. Parallel async execution with asyncio.gather + +class TestAsyncParallelExecution: + """Prove that multiple async execute() calls can be gathered in parallel.""" + + @_CONFIG_PATCH + @patch("polyapi.http_client.async_post", new_callable=AsyncMock) + def test_gather_multiple_executes(self, mock_post, _config): + # Each call returns a distinct response + responses = [_fake_response(200, text=f'"result-{i}"') for i in range(5)] + mock_post.side_effect = responses + + async def _run(): + coros = [execute("server", f"fn-{i}", {}) for i in range(5)] + # All should be awaitables in async context + for c in coros: + assert inspect.isawaitable(c) + return await asyncio.gather(*coros) + + results = asyncio.run(_run()) + assert len(results) == 5 + assert mock_post.call_count == 5 + # Verify each got a distinct response + for i, r in enumerate(results): + assert r.text == f'"result-{i}"' + + @_CONFIG_PATCH + @patch("polyapi.http_client.async_post", new_callable=AsyncMock) + def test_gather_is_faster_than_sequential(self, mock_post, _config): + """Simulate latency: each call sleeps 0.1s. Gathering 5 should take + ~0.1s total (parallel) rather than ~0.5s (sequential).""" + import time + + async def _slow_post(*args, **kwargs): + await asyncio.sleep(0.1) + return _fake_response(200, text='"done"') + + mock_post.side_effect = _slow_post + + async def _run(): + start = time.monotonic() + coros = [execute("server", f"fn-{i}", {}) for i in range(5)] + results = await asyncio.gather(*coros) + elapsed = time.monotonic() - start + return results, elapsed + + results, elapsed = asyncio.run(_run()) + assert len(results) == 5 + # Parallel should finish well under 0.5s (sequential would be ~0.5s) + assert elapsed < 0.3, f"Parallel gather took {elapsed:.2f}s — expected < 0.3s" + + +# 8. Helper: _build_direct_execute_params + +class TestBuildDirectExecuteParams: + def test_strips_url(self): + params = _build_direct_execute_params({"url": "https://x.com", "method": "GET"}) + assert "url" not in params + assert params["method"] == "GET" + + def test_converts_max_redirects_positive(self): + params = _build_direct_execute_params({"url": "u", "maxRedirects": 5}) + assert "maxRedirects" not in params + assert params["follow_redirects"] is True + + def test_converts_max_redirects_zero(self): + params = _build_direct_execute_params({"url": "u", "maxRedirects": 0}) + assert params["follow_redirects"] is False + + def test_no_mutation_of_input(self): + original = {"url": "u", "method": "POST", "maxRedirects": 3} + _build_direct_execute_params(original) + # Original dict should be unchanged + assert "url" in original + assert "maxRedirects" in original From 1dd93a6002affd2c807d7fec022d329b0d442400 Mon Sep 17 00:00:00 2001 From: harshi922 Date: Wed, 18 Feb 2026 12:46:57 -0500 Subject: [PATCH 08/17] one check error --- polyapi/execute.py | 12 ++---------- tests/test_async_proof.py | 25 +++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/polyapi/execute.py b/polyapi/execute.py index 33546bf..e0b6eaf 100644 --- a/polyapi/execute.py +++ b/polyapi/execute.py @@ -19,14 +19,6 @@ def _check_endpoint_error(resp, function_type, function_id, data): raise PolyApiException(f"{resp.status_code}: {error_content}") -def _check_response_error(resp, function_type, function_id, data): - if resp.status_code < 200 or resp.status_code >= 300: - error_content = resp.content.decode("utf-8", errors="ignore") - if function_type == 'api' and os.getenv("LOGS_ENABLED"): - logger.error(f"Error executing api function with id: {function_id}. Status code: {resp.status_code}. Request data: {data}, Response: {error_content}") - elif function_type != 'api': - raise PolyApiException(f"{resp.status_code}: {error_content}") - def _build_direct_execute_params(endpoint_info_data): request_params = endpoint_info_data.copy() @@ -63,7 +55,7 @@ def _sync_direct_execute(function_type, function_id, data) -> httpx.Response: **request_params ) - _check_response_error(resp, function_type, function_id, data) + _check_endpoint_error(resp, function_type, function_id, data) return resp @@ -94,7 +86,7 @@ async def _async_direct_execute(function_type, function_id, data) -> httpx.Respo **request_params ) - _check_response_error(resp, function_type, function_id, data) + _check_endpoint_error(resp, function_type, function_id, data) return resp diff --git a/tests/test_async_proof.py b/tests/test_async_proof.py index 805c553..e676cba 100644 --- a/tests/test_async_proof.py +++ b/tests/test_async_proof.py @@ -24,12 +24,12 @@ variable_get, variable_update, _build_direct_execute_params, + _check_endpoint_error, ) +from polyapi.exceptions import PolyApiException -# --------------------------------------------------------------------------- # Helpers -# --------------------------------------------------------------------------- def _fake_response(status_code=200, json_data=None, text="ok"): """Build a fake httpx.Response.""" @@ -378,3 +378,24 @@ def test_no_mutation_of_input(self): # Original dict should be unchanged assert "url" in original assert "maxRedirects" in original + + +# 9. _check_endpoint_error + +class TestCheckEndpointError: + """_check_endpoint_error raises on errors for both api and non-api types.""" + + def test_raises_for_api_with_logs(self): + resp = _fake_response(status_code=500, text="server broke") + with patch.dict("os.environ", {"LOGS_ENABLED": "1"}): + with pytest.raises(PolyApiException, match="500"): + _check_endpoint_error(resp, "api", "fn-1", {}) + + def test_raises_for_non_api(self): + resp = _fake_response(status_code=500, text="server broke") + with pytest.raises(PolyApiException, match="500"): + _check_endpoint_error(resp, "server", "fn-1", {}) + + def test_no_raise_on_success(self): + resp = _fake_response(status_code=200, text="ok") + _check_endpoint_error(resp, "api", "fn-1", {}) From ad4c5cb4b36ccc6dd61d8cc905b3d6770fa86fa1 Mon Sep 17 00:00:00 2001 From: harshi922 Date: Wed, 18 Feb 2026 14:08:46 -0500 Subject: [PATCH 09/17] all fixed func way --- polyapi/api.py | 19 ++++++ polyapi/auth.py | 33 +++++++++ polyapi/execute.py | 79 +++++++++++++--------- polyapi/http_client.py | 12 +--- polyapi/server.py | 14 ++++ polyapi/utils.py | 2 +- polyapi/variables.py | 10 +++ tests/test_async_proof.py | 138 +++++++++++++++++++------------------- 8 files changed, 195 insertions(+), 112 deletions(-) diff --git a/polyapi/api.py b/polyapi/api.py index fc243ba..5b0ffde 100644 --- a/polyapi/api.py +++ b/polyapi/api.py @@ -35,6 +35,25 @@ def {function_name}( return {api_response_type}(resp.json()) # type: ignore +async def {function_name}_async( +{args} +) -> {api_response_type}: + \"""{function_description} + + Function ID: {function_id} + \""" + if get_direct_execute_config(): + resp = await direct_execute_async("{function_type}", "{function_id}", {data}) + return {api_response_type}({{ + "status": resp.status_code, + "headers": dict(resp.headers), + "data": resp.json() + }}) # type: ignore + else: + resp = await execute_async("{function_type}", "{function_id}", {data}) + return {api_response_type}(resp.json()) # type: ignore + + """ diff --git a/polyapi/auth.py b/polyapi/auth.py index 3d6c325..66a07e0 100644 --- a/polyapi/auth.py +++ b/polyapi/auth.py @@ -117,6 +117,16 @@ def introspectToken(token: str) -> AuthFunctionResponse: url = "/auth-providers/{function_id}/introspect" resp = execute_post(url, {{"token": token}}) return resp.json() + + +async def introspectToken_async(token: str) -> AuthFunctionResponse: + \"""{description} + + Function ID: {function_id} + \""" + url = "/auth-providers/{function_id}/introspect" + resp = await execute_post_async(url, {{"token": token}}) + return resp.json() """ REFRESH_TOKEN_TEMPLATE = """ @@ -128,6 +138,16 @@ def refreshToken(token: str) -> AuthFunctionResponse: url = "/auth-providers/{function_id}/refresh" resp = execute_post(url, {{"token": token}}) return resp.json() + + +async def refreshToken_async(token: str) -> AuthFunctionResponse: + \"""{description} + + Function ID: {function_id} + \""" + url = "/auth-providers/{function_id}/refresh" + resp = await execute_post_async(url, {{"token": token}}) + return resp.json() """ REVOKE_TOKEN_TEMPLATE = """ @@ -142,6 +162,19 @@ def revokeToken(token: str) -> Optional[AuthFunctionResponse]: return resp.json() except: return None + + +async def revokeToken_async(token: str) -> Optional[AuthFunctionResponse]: + \"""{description} + + Function ID: {function_id} + \""" + url = "/auth-providers/{function_id}/revoke" + resp = await execute_post_async(url, {{"token": token}}) + try: + return resp.json() + except: + return None """ diff --git a/polyapi/execute.py b/polyapi/execute.py index e0b6eaf..41dc7f8 100644 --- a/polyapi/execute.py +++ b/polyapi/execute.py @@ -1,5 +1,3 @@ -from typing import Union -from collections.abc import Coroutine import httpx import os import logging @@ -41,17 +39,22 @@ def _sync_direct_execute(function_type, function_id, data) -> httpx.Response: has_mtls, cert_path, key_path, ca_path = get_mtls_config() + # Direct-execute hits URL that may need custom TLS + # settings (mTLS certs or disabled verification). httpx Client.request() + # doesn't accept per-request transport kwargs, so use one-off calls. if has_mtls: - resp = http_client.request( + resp = httpx.request( url=endpoint_info_data["url"], cert=(cert_path, key_path), verify=ca_path, + timeout=None, **request_params ) else: - resp = http_client.request( + resp = httpx.request( url=endpoint_info_data["url"], verify=False, + timeout=None, **request_params ) @@ -72,32 +75,36 @@ async def _async_direct_execute(function_type, function_id, data) -> httpx.Respo has_mtls, cert_path, key_path, ca_path = get_mtls_config() + # One-off async client for custom TLS settings on external URLs. if has_mtls: - resp = await http_client.async_request( - url=endpoint_info_data["url"], - cert=(cert_path, key_path), - verify=ca_path, - **request_params - ) + async with httpx.AsyncClient( + cert=(cert_path, key_path), verify=ca_path, timeout=None + ) as client: + resp = await client.request( + url=endpoint_info_data["url"], **request_params + ) else: - resp = await http_client.async_request( - url=endpoint_info_data["url"], - verify=False, - **request_params - ) + async with httpx.AsyncClient(verify=False, timeout=None) as client: + resp = await client.request( + url=endpoint_info_data["url"], **request_params + ) _check_endpoint_error(resp, function_type, function_id, data) return resp -def direct_execute(function_type, function_id, data) -> Union[httpx.Response, Coroutine]: - """ execute a specific function id/type +def direct_execute(function_type, function_id, data) -> httpx.Response: + """ execute a specific function id/type (sync) """ - if http_client.is_async(): - return _async_direct_execute(function_type, function_id, data) return _sync_direct_execute(function_type, function_id, data) +async def direct_execute_async(function_type, function_id, data) -> httpx.Response: + """ execute a specific function id/type (async) + """ + return await _async_direct_execute(function_type, function_id, data) + + def _sync_execute(function_type, function_id, data) -> httpx.Response: api_key, api_url = get_api_key_and_url() headers = {"Authorization": f"Bearer {api_key}"} @@ -132,14 +139,18 @@ async def _async_execute(function_type, function_id, data) -> httpx.Response: return resp -def execute(function_type, function_id, data) -> Union[httpx.Response, Coroutine]: - """ execute a specific function id/type +def execute(function_type, function_id, data) -> httpx.Response: + """ execute a specific function id/type (sync) """ - if http_client.is_async(): - return _async_execute(function_type, function_id, data) return _sync_execute(function_type, function_id, data) +async def execute_async(function_type, function_id, data) -> httpx.Response: + """ execute a specific function id/type (async) + """ + return await _async_execute(function_type, function_id, data) + + def _sync_execute_post(path, data): api_key, api_url = get_api_key_and_url() headers = {"Authorization": f"Bearer {api_key}"} @@ -153,11 +164,13 @@ async def _async_execute_post(path, data): def execute_post(path, data): - if http_client.is_async(): - return _async_execute_post(path, data) return _sync_execute_post(path, data) +async def execute_post_async(path, data): + return await _async_execute_post(path, data) + + def _sync_variable_get(variable_id: str) -> httpx.Response: api_key, base_url = get_api_key_and_url() headers = {"Authorization": f"Bearer {api_key}"} @@ -180,12 +193,14 @@ async def _async_variable_get(variable_id: str) -> httpx.Response: return resp -def variable_get(variable_id: str) -> Union[httpx.Response, Coroutine]: - if http_client.is_async(): - return _async_variable_get(variable_id) +def variable_get(variable_id: str) -> httpx.Response: return _sync_variable_get(variable_id) +async def variable_get_async(variable_id: str) -> httpx.Response: + return await _async_variable_get(variable_id) + + def _sync_variable_update(variable_id: str, value) -> httpx.Response: api_key, base_url = get_api_key_and_url() headers = {"Authorization": f"Bearer {api_key}"} @@ -208,7 +223,9 @@ async def _async_variable_update(variable_id: str, value) -> httpx.Response: return resp -def variable_update(variable_id: str, value) -> Union[httpx.Response, Coroutine]: - if http_client.is_async(): - return _async_variable_update(variable_id, value) +def variable_update(variable_id: str, value) -> httpx.Response: return _sync_variable_update(variable_id, value) + + +async def variable_update_async(variable_id: str, value) -> httpx.Response: + return await _async_variable_update(variable_id, value) diff --git a/polyapi/http_client.py b/polyapi/http_client.py index 217a23f..fcc9be2 100644 --- a/polyapi/http_client.py +++ b/polyapi/http_client.py @@ -5,25 +5,17 @@ _async_client: httpx.AsyncClient | None = None -def is_async() -> bool: - try: - asyncio.get_running_loop() - return True - except RuntimeError: - return False - - def _get_sync_client() -> httpx.Client: global _sync_client if _sync_client is None: - _sync_client = httpx.Client(verify=False, timeout=None) + _sync_client = httpx.Client(timeout=None) return _sync_client def _get_async_client() -> httpx.AsyncClient: global _async_client if _async_client is None: - _async_client = httpx.AsyncClient(verify=False, timeout=None) + _async_client = httpx.AsyncClient(timeout=None) return _async_client diff --git a/polyapi/server.py b/polyapi/server.py index 53b173e..9ee8ae0 100644 --- a/polyapi/server.py +++ b/polyapi/server.py @@ -24,6 +24,20 @@ def {function_name}( return resp.text # type: ignore # fallback for debugging +async def {function_name}_async( +{args} +) -> {return_type_name}: + \"""{function_description} + + Function ID: {function_id} + \""" + resp = await execute_async("{function_type}", "{function_id}", {data}) + try: + return {return_action} + except: + return resp.text # type: ignore # fallback for debugging + + """ diff --git a/polyapi/utils.py b/polyapi/utils.py index 6a7b475..02d7897 100644 --- a/polyapi/utils.py +++ b/polyapi/utils.py @@ -16,7 +16,7 @@ # this string should be in every __init__ file. # it contains all the imports needed for the function or variable code to run -CODE_IMPORTS = "from typing import List, Dict, Any, Optional, Callable\nfrom typing_extensions import TypedDict, NotRequired\nimport logging\nimport requests\nimport socketio # type: ignore\nfrom polyapi.config import get_api_key_and_url, get_direct_execute_config\nfrom polyapi.execute import execute, execute_post, variable_get, variable_update, direct_execute\n\n" +CODE_IMPORTS = "from typing import List, Dict, Any, Optional, Callable\nfrom typing_extensions import TypedDict, NotRequired\nimport logging\nimport requests\nimport socketio # type: ignore\nfrom polyapi.config import get_api_key_and_url, get_direct_execute_config\nfrom polyapi.execute import execute, execute_async, execute_post, execute_post_async, variable_get, variable_get_async, variable_update, variable_update_async, direct_execute, direct_execute_async\n\n" def init_the_init(full_path: str, code_imports: Optional[str] = None) -> None: diff --git a/polyapi/variables.py b/polyapi/variables.py index 1fb915d..b8220b7 100644 --- a/polyapi/variables.py +++ b/polyapi/variables.py @@ -15,6 +15,11 @@ def get() -> {variable_type}: resp = variable_get("{variable_id}") return resp.text + + @staticmethod + async def get_async() -> {variable_type}: + resp = await variable_get_async("{variable_id}") + return resp.text """ @@ -30,6 +35,11 @@ def update(value: {variable_type}): resp = variable_update("{variable_id}", value) return resp.json() + @staticmethod + async def update_async(value: {variable_type}): + resp = await variable_update_async("{variable_id}", value) + return resp.json() + @classmethod async def onUpdate(cls, callback): api_key, base_url = get_api_key_and_url() diff --git a/tests/test_async_proof.py b/tests/test_async_proof.py index e676cba..61e0a6f 100644 --- a/tests/test_async_proof.py +++ b/tests/test_async_proof.py @@ -1,16 +1,15 @@ -"""Tests proving the sync/async dual-mode pattern works correctly. +"""Tests proving the sync/async split works correctly. These tests mock HTTP calls so no live server is needed. They verify that: 1. is_async() correctly detects sync vs async context 2. http_client uses sync Client in sync context, AsyncClient in async context -3. execute(), direct_execute(), execute_post(), variable_get(), variable_update() - all return the right type based on calling context -4. Parallel async execution with asyncio.gather works +3. Sync functions (execute, direct_execute, etc.) always return httpx.Response +4. Async functions (execute_async, direct_execute_async, etc.) return coroutines +5. Parallel async execution with asyncio.gather works """ import asyncio import inspect -from collections.abc import Coroutine from unittest.mock import patch, MagicMock, AsyncMock import httpx @@ -19,10 +18,15 @@ from polyapi import http_client from polyapi.execute import ( execute, + execute_async, direct_execute, + direct_execute_async, execute_post, + execute_post_async, variable_get, + variable_get_async, variable_update, + variable_update_async, _build_direct_execute_params, _check_endpoint_error, ) @@ -41,33 +45,7 @@ def _fake_response(status_code=200, json_data=None, text="ok"): return resp -# 1. is_async() detection - -class TestIsAsync: - def test_sync_context_returns_false(self): - assert http_client.is_async() is False - - def test_async_context_returns_true(self): - async def _check(): - return http_client.is_async() - - result = asyncio.run(_check()) - assert result is True - - def test_nested_sync_inside_async_still_true(self): - """A plain (non-async) helper called from within an event loop - should still report True because the loop is running.""" - - def sync_helper(): - return http_client.is_async() - - async def _check(): - return sync_helper() - - assert asyncio.run(_check()) is True - - -# 2. http_client sync / async client pairing +# 1. http_client sync / async client pairing class TestHttpClientPairing: """Verify that the sync helpers call httpx.Client and the async helpers @@ -157,7 +135,7 @@ async def _run(): mock_request.assert_called_once() -# 3. execute() dual-mode +# 3. execute() / execute_async() _CONFIG_PATCH = patch( "polyapi.execute.get_api_key_and_url", @@ -169,8 +147,9 @@ async def _run(): ) -class TestExecuteDualMode: - """execute() returns httpx.Response in sync context, coroutine in async.""" +class TestExecute: + """execute() always returns httpx.Response (sync). + execute_async() always returns a coroutine that resolves to httpx.Response.""" @_CONFIG_PATCH @patch("polyapi.http_client.post", return_value=_fake_response(200, text='"hello"')) @@ -184,8 +163,7 @@ def test_sync_returns_response(self, mock_post, _mock_config): @patch("polyapi.http_client.async_post", new_callable=AsyncMock, return_value=_fake_response(200, text='"hello"')) def test_async_returns_coroutine_then_response(self, mock_post, _mock_config): async def _run(): - coro = execute("server", "some-id", {}) - # In async context, execute() returns a coroutine + coro = execute_async("server", "some-id", {}) assert inspect.isawaitable(coro) return await coro @@ -202,10 +180,23 @@ def test_sync_calls_correct_url(self, mock_post, _mock_config): assert call_args[1]["json"] == {"arg": 1} assert "Bearer fake-key" in call_args[1]["headers"]["Authorization"] + @_CONFIG_PATCH + @patch("polyapi.http_client.post", return_value=_fake_response(200, text='"hello"')) + def test_sync_works_inside_async_context(self, mock_post, _mock_config): + """execute() (sync) should still return httpx.Response even when + called from within an async context — this is the key fix.""" + async def _run(): + result = execute("server", "some-id", {}) + # Should be a Response, NOT a coroutine + assert not inspect.isawaitable(result) + assert result.status_code == 200 + + asyncio.run(_run()) + -# 4. direct_execute() dual-mode +# 4. direct_execute() / direct_execute_async() -class TestDirectExecuteDualMode: +class TestDirectExecute: @_CONFIG_PATCH @_MTLS_PATCH @@ -216,9 +207,7 @@ class TestDirectExecuteDualMode: def test_sync_returns_response(self, mock_post, mock_request, _mtls, _config): result = direct_execute("server", "fn-id", {}) assert result.status_code == 200 - # First call: POST to /direct-execute endpoint info assert "/direct-execute" in mock_post.call_args[0][0] - # Second call: actual request to the target URL mock_request.assert_called_once() @_CONFIG_PATCH @@ -229,7 +218,7 @@ def test_sync_returns_response(self, mock_post, mock_request, _mtls, _config): )) def test_async_returns_coroutine(self, mock_post, mock_request, _mtls, _config): async def _run(): - coro = direct_execute("server", "fn-id", {}) + coro = direct_execute_async("server", "fn-id", {}) assert inspect.isawaitable(coro) return await coro @@ -237,9 +226,9 @@ async def _run(): assert result.status_code == 200 -# 5. execute_post() dual-mode +# 5. execute_post() / execute_post_async() -class TestExecutePostDualMode: +class TestExecutePost: @_CONFIG_PATCH @patch("polyapi.http_client.post", return_value=_fake_response()) @@ -252,7 +241,7 @@ def test_sync(self, mock_post, _config): @patch("polyapi.http_client.async_post", new_callable=AsyncMock, return_value=_fake_response()) def test_async(self, mock_post, _config): async def _run(): - coro = execute_post("/some/path", {"data": 1}) + coro = execute_post_async("/some/path", {"data": 1}) assert inspect.isawaitable(coro) return await coro @@ -260,9 +249,9 @@ async def _run(): assert result.status_code == 200 -# 6. variable_get / variable_update dual-mode +# 6. variable_get / variable_get_async -class TestVariableGetDualMode: +class TestVariableGet: @_CONFIG_PATCH @patch("polyapi.http_client.get", return_value=_fake_response(200, text="42")) @@ -275,7 +264,7 @@ def test_sync(self, mock_get, _config): @patch("polyapi.http_client.async_get", new_callable=AsyncMock, return_value=_fake_response(200, text="42")) def test_async(self, mock_get, _config): async def _run(): - coro = variable_get("var-123") + coro = variable_get_async("var-123") assert inspect.isawaitable(coro) return await coro @@ -283,7 +272,9 @@ async def _run(): assert result.status_code == 200 -class TestVariableUpdateDualMode: +# 7. variable_update / variable_update_async + +class TestVariableUpdate: @_CONFIG_PATCH @patch("polyapi.http_client.patch", return_value=_fake_response(200)) @@ -295,7 +286,7 @@ def test_sync(self, mock_patch_call, _config): @patch("polyapi.http_client.async_patch", new_callable=AsyncMock, return_value=_fake_response(200)) def test_async(self, mock_patch_call, _config): async def _run(): - coro = variable_update("var-123", "new-value") + coro = variable_update_async("var-123", "new-value") assert inspect.isawaitable(coro) return await coro @@ -303,21 +294,19 @@ async def _run(): assert result.status_code == 200 -# 7. Parallel async execution with asyncio.gather +# 8. Parallel async execution with asyncio.gather class TestAsyncParallelExecution: - """Prove that multiple async execute() calls can be gathered in parallel.""" + """Prove that multiple async execute_async() calls can be gathered in parallel.""" @_CONFIG_PATCH @patch("polyapi.http_client.async_post", new_callable=AsyncMock) def test_gather_multiple_executes(self, mock_post, _config): - # Each call returns a distinct response responses = [_fake_response(200, text=f'"result-{i}"') for i in range(5)] mock_post.side_effect = responses async def _run(): - coros = [execute("server", f"fn-{i}", {}) for i in range(5)] - # All should be awaitables in async context + coros = [execute_async("server", f"fn-{i}", {}) for i in range(5)] for c in coros: assert inspect.isawaitable(c) return await asyncio.gather(*coros) @@ -325,15 +314,15 @@ async def _run(): results = asyncio.run(_run()) assert len(results) == 5 assert mock_post.call_count == 5 - # Verify each got a distinct response for i, r in enumerate(results): assert r.text == f'"result-{i}"' @_CONFIG_PATCH @patch("polyapi.http_client.async_post", new_callable=AsyncMock) def test_gather_is_faster_than_sequential(self, mock_post, _config): - """Simulate latency: each call sleeps 0.1s. Gathering 5 should take - ~0.1s total (parallel) rather than ~0.5s (sequential).""" + """Measure both sequential and parallel execution in the same run, + then assert parallel < sequential. Avoids flaky CI failures from + hardcoded time thresholds.""" import time async def _slow_post(*args, **kwargs): @@ -343,19 +332,29 @@ async def _slow_post(*args, **kwargs): mock_post.side_effect = _slow_post async def _run(): - start = time.monotonic() - coros = [execute("server", f"fn-{i}", {}) for i in range(5)] - results = await asyncio.gather(*coros) - elapsed = time.monotonic() - start - return results, elapsed - - results, elapsed = asyncio.run(_run()) + # Sequential: await one at a time + seq_start = time.monotonic() + for i in range(5): + await execute_async("server", f"fn-{i}", {}) + seq_elapsed = time.monotonic() - seq_start + + # Parallel: gather all at once + par_start = time.monotonic() + results = await asyncio.gather( + *[execute_async("server", f"fn-{i}", {}) for i in range(5)] + ) + par_elapsed = time.monotonic() - par_start + + return results, seq_elapsed, par_elapsed + + results, seq_elapsed, par_elapsed = asyncio.run(_run()) assert len(results) == 5 - # Parallel should finish well under 0.5s (sequential would be ~0.5s) - assert elapsed < 0.3, f"Parallel gather took {elapsed:.2f}s — expected < 0.3s" + assert par_elapsed < seq_elapsed, ( + f"Parallel ({par_elapsed:.2f}s) should be faster than sequential ({seq_elapsed:.2f}s)" + ) -# 8. Helper: _build_direct_execute_params +# 9. Helper: _build_direct_execute_params class TestBuildDirectExecuteParams: def test_strips_url(self): @@ -375,12 +374,11 @@ def test_converts_max_redirects_zero(self): def test_no_mutation_of_input(self): original = {"url": "u", "method": "POST", "maxRedirects": 3} _build_direct_execute_params(original) - # Original dict should be unchanged assert "url" in original assert "maxRedirects" in original -# 9. _check_endpoint_error +# 10. _check_endpoint_error class TestCheckEndpointError: """_check_endpoint_error raises on errors for both api and non-api types.""" From 9859eaa0b0527fe8effb6f97aeec02673035f258 Mon Sep 17 00:00:00 2001 From: harshi922 Date: Wed, 18 Feb 2026 14:10:12 -0500 Subject: [PATCH 10/17] all fixed func way tests --- tests/test_async_proof.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/test_async_proof.py b/tests/test_async_proof.py index 61e0a6f..8659920 100644 --- a/tests/test_async_proof.py +++ b/tests/test_async_proof.py @@ -41,7 +41,7 @@ def _fake_response(status_code=200, json_data=None, text="ok"): resp.status_code = status_code resp.text = text resp.content = text.encode() - resp.json.return_value = json_data or {} + resp.json.return_value = {} if json_data is None else json_data return resp @@ -200,7 +200,7 @@ class TestDirectExecute: @_CONFIG_PATCH @_MTLS_PATCH - @patch("polyapi.http_client.request", return_value=_fake_response(200, text='{"result": 1}')) + @patch("polyapi.execute.httpx.request", return_value=_fake_response(200, text='{"result": 1}')) @patch("polyapi.http_client.post", return_value=_fake_response( 200, json_data={"url": "https://target.example.com", "method": "GET"}, )) @@ -212,15 +212,20 @@ def test_sync_returns_response(self, mock_post, mock_request, _mtls, _config): @_CONFIG_PATCH @_MTLS_PATCH - @patch("polyapi.http_client.async_request", new_callable=AsyncMock, return_value=_fake_response(200)) @patch("polyapi.http_client.async_post", new_callable=AsyncMock, return_value=_fake_response( 200, json_data={"url": "https://target.example.com", "method": "GET"}, )) - def test_async_returns_coroutine(self, mock_post, mock_request, _mtls, _config): + def test_async_returns_coroutine(self, mock_post, _mtls, _config): + mock_client = AsyncMock() + mock_client.request = AsyncMock(return_value=_fake_response(200)) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + async def _run(): - coro = direct_execute_async("server", "fn-id", {}) - assert inspect.isawaitable(coro) - return await coro + with patch("polyapi.execute.httpx.AsyncClient", return_value=mock_client): + coro = direct_execute_async("server", "fn-id", {}) + assert inspect.isawaitable(coro) + return await coro result = asyncio.run(_run()) assert result.status_code == 200 From 156c87a5f10814770228e919e4de6be801f3bc1e Mon Sep 17 00:00:00 2001 From: harshi922 Date: Thu, 19 Feb 2026 11:14:52 -0500 Subject: [PATCH 11/17] Add seperate closes and revert two check errors --- polyapi/execute.py | 29 +++++++++++------------------ polyapi/http_client.py | 10 +++++++--- polyapi/utils.py | 1 - tests/test_async_proof.py | 26 +++++++++++++++++--------- 4 files changed, 35 insertions(+), 31 deletions(-) diff --git a/polyapi/execute.py b/polyapi/execute.py index 41dc7f8..b00de69 100644 --- a/polyapi/execute.py +++ b/polyapi/execute.py @@ -7,6 +7,14 @@ logger = logging.getLogger("poly") +def _check_response_error(resp, function_type, function_id, data): + if resp.status_code < 200 or resp.status_code >= 300: + error_content = resp.content.decode("utf-8", errors="ignore") + if function_type == 'api' and os.getenv("LOGS_ENABLED"): + logger.error(f"Error executing api function with id: {function_id}. Status code: {resp.status_code}. Request data: {data}, Response: {error_content}") + elif function_type != 'api': + raise PolyApiException(f"{resp.status_code}: {error_content}") + def _check_endpoint_error(resp, function_type, function_id, data): if resp.status_code < 200 or resp.status_code >= 300: @@ -17,7 +25,6 @@ def _check_endpoint_error(resp, function_type, function_id, data): raise PolyApiException(f"{resp.status_code}: {error_content}") - def _build_direct_execute_params(endpoint_info_data): request_params = endpoint_info_data.copy() request_params.pop("url", None) @@ -58,7 +65,7 @@ def _sync_direct_execute(function_type, function_id, data) -> httpx.Response: **request_params ) - _check_endpoint_error(resp, function_type, function_id, data) + _check_response_error(resp, function_type, function_id, data) return resp @@ -111,14 +118,7 @@ def _sync_execute(function_type, function_id, data) -> httpx.Response: url = f"{api_url}/functions/{function_type}/{function_id}/execute" resp = http_client.post(url, json=data, headers=headers) - - if (resp.status_code < 200 or resp.status_code >= 300) and os.getenv("LOGS_ENABLED"): - error_content = resp.content.decode("utf-8", errors="ignore") - if function_type == 'api' and os.getenv("LOGS_ENABLED"): - logger.error(f"Error executing api function with id: {function_id}. Status code: {resp.status_code}. Request data: {data}, Response: {error_content}") - elif function_type != 'api': - raise PolyApiException(f"{resp.status_code}: {error_content}") - + _check_response_error(resp, function_type, function_id, data) return resp @@ -128,14 +128,7 @@ async def _async_execute(function_type, function_id, data) -> httpx.Response: url = f"{api_url}/functions/{function_type}/{function_id}/execute" resp = await http_client.async_post(url, json=data, headers=headers) - - if (resp.status_code < 200 or resp.status_code >= 300) and os.getenv("LOGS_ENABLED"): - error_content = resp.content.decode("utf-8", errors="ignore") - if function_type == 'api' and os.getenv("LOGS_ENABLED"): - logger.error(f"Error executing api function with id: {function_id}. Status code: {resp.status_code}. Request data: {data}, Response: {error_content}") - elif function_type != 'api': - raise PolyApiException(f"{resp.status_code}: {error_content}") - + _check_response_error(resp, function_type, function_id, data) return resp diff --git a/polyapi/http_client.py b/polyapi/http_client.py index fcc9be2..c97d06f 100644 --- a/polyapi/http_client.py +++ b/polyapi/http_client.py @@ -60,10 +60,14 @@ async def async_request(method, url, **kwargs) -> httpx.Response: def close(): - global _sync_client, _async_client + global _sync_client if _sync_client is not None: _sync_client.close() _sync_client = None + +async def close_async(): + global _sync_client, _async_client + close() if _async_client is not None: - asyncio.get_event_loop().run_until_complete(_async_client.aclose()) - _async_client = None + await _async_client.aclose() + _async_client = None \ No newline at end of file diff --git a/polyapi/utils.py b/polyapi/utils.py index db3c7a5..1a6d168 100644 --- a/polyapi/utils.py +++ b/polyapi/utils.py @@ -73,7 +73,6 @@ def print_red(s: str): def add_type_import_path(function_name: str, arg: str) -> str: """if not basic type, coerce to camelCase and add the import path""" - # outdated og comment - for now, just treat Callables as basic types # from now, we start qualifying non-basic types :)) # e.g. Callable[[EmailAddress, Dict, Dict, Dict], None] # becomes Callable[[Set_profile_email.EmailAddress, Dict, Dict, Dict], None] diff --git a/tests/test_async_proof.py b/tests/test_async_proof.py index 8659920..88efd74 100644 --- a/tests/test_async_proof.py +++ b/tests/test_async_proof.py @@ -29,6 +29,7 @@ variable_update_async, _build_direct_execute_params, _check_endpoint_error, + _check_response_error ) from polyapi.exceptions import PolyApiException @@ -383,22 +384,29 @@ def test_no_mutation_of_input(self): assert "maxRedirects" in original -# 10. _check_endpoint_error +# 10. _check_endpoint_error vs _check_response_error -class TestCheckEndpointError: - """_check_endpoint_error raises on errors for both api and non-api types.""" +class TestCheckErrorBehaviorDifference: + """_check_endpoint_error raises on api errors; _check_response_error only logs.""" + + + def test_endpoint_error_raises_for_api_with_logs(self): + """_check_endpoint_error raises PolyApiException for api functions.""" - def test_raises_for_api_with_logs(self): resp = _fake_response(status_code=500, text="server broke") with patch.dict("os.environ", {"LOGS_ENABLED": "1"}): with pytest.raises(PolyApiException, match="500"): _check_endpoint_error(resp, "api", "fn-1", {}) - def test_raises_for_non_api(self): + def test_response_error_logs_for_api_with_logs(self): + """_check_response_error only logs (no raise) for api functions.""" + resp = _fake_response(status_code=500, text="server broke") + with patch.dict("os.environ", {"LOGS_ENABLED": "1"}): + # Should NOT raise — just logs + _check_response_error(resp, "api", "fn-1", {}) + + def test_both_raise_for_non_api(self): + """Both functions raise PolyApiException for non-api function types.""" resp = _fake_response(status_code=500, text="server broke") with pytest.raises(PolyApiException, match="500"): _check_endpoint_error(resp, "server", "fn-1", {}) - - def test_no_raise_on_success(self): - resp = _fake_response(status_code=200, text="ok") - _check_endpoint_error(resp, "api", "fn-1", {}) From 7d56f642ab0483bcddae64a82cc1424a0ab96dbd Mon Sep 17 00:00:00 2001 From: harshi922 Date: Thu, 19 Feb 2026 16:07:58 -0500 Subject: [PATCH 12/17] consistency changes --- polyapi/execute.py | 2 +- polyapi/poly_tables.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/polyapi/execute.py b/polyapi/execute.py index b00de69..d0bf9e3 100644 --- a/polyapi/execute.py +++ b/polyapi/execute.py @@ -96,7 +96,7 @@ async def _async_direct_execute(function_type, function_id, data) -> httpx.Respo url=endpoint_info_data["url"], **request_params ) - _check_endpoint_error(resp, function_type, function_id, data) + _check_response_error(resp, function_type, function_id, data) return resp diff --git a/polyapi/poly_tables.py b/polyapi/poly_tables.py index 87dd879..4a391fa 100644 --- a/polyapi/poly_tables.py +++ b/polyapi/poly_tables.py @@ -88,7 +88,7 @@ def execute_query(table_id, method, query): headers = {"x-poly-execution-id": polyCustom.get("executionId")} if auth_key: headers["Authorization"] = f"Bearer {auth_key}" - response = requests.post(url, json=query, headers=headers) + response = http_client.post(url, json=query, headers=headers) response.raise_for_status() return response.json() except Exception as e: From 2d6ab56bf438a4cf3729247f76f828372edceb85 Mon Sep 17 00:00:00 2001 From: harshi922 Date: Thu, 19 Feb 2026 17:58:20 -0500 Subject: [PATCH 13/17] bump and dependency --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8ecac6c..d651f61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "polyapi-python" -version = "0.3.13.dev3" +version = "0.3.14.dev1" description = "The Python Client for PolyAPI, the IPaaS by Developers for Developers" authors = [{ name = "Dan Fellin", email = "dan@polyapi.io" }] dependencies = [ @@ -16,6 +16,7 @@ dependencies = [ "colorama==0.4.4", "python-socketio[asyncio_client]==5.11.1", "truststore>=0.8.0", + "httpx>=0.28.1" ] readme = "README.md" license = { file = "LICENSE" } From 78175f83fdd390b02203afc53385a2ae3f4b76d6 Mon Sep 17 00:00:00 2001 From: harshi922 Date: Thu, 19 Feb 2026 17:59:14 -0500 Subject: [PATCH 14/17] bump and dependency --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b5eb3c4..1a07d23 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ pydantic>=2.8.0 stdlib_list>=0.10.0 colorama==0.4.4 python-socketio[asyncio_client]==5.11.1 -truststore>=0.8.0 \ No newline at end of file +truststore>=0.8.0 +httpx>=0.28.1 \ No newline at end of file From c118a13867d8fc28099e4cb6608d770cf4d51796 Mon Sep 17 00:00:00 2001 From: harshi922 Date: Thu, 19 Feb 2026 18:01:00 -0500 Subject: [PATCH 15/17] bump undo --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d651f61..491e3d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "polyapi-python" -version = "0.3.14.dev1" +version = "0.3.13" description = "The Python Client for PolyAPI, the IPaaS by Developers for Developers" authors = [{ name = "Dan Fellin", email = "dan@polyapi.io" }] dependencies = [ From 710fc091964167f22d48db9b952ec12adf4a71e3 Mon Sep 17 00:00:00 2001 From: harshi922 Date: Fri, 20 Feb 2026 12:32:04 -0500 Subject: [PATCH 16/17] fix tests --- tests/test_rendered_spec.py | 2 +- tests/test_tabi.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_rendered_spec.py b/tests/test_rendered_spec.py index f2bf7a5..9c43f07 100644 --- a/tests/test_rendered_spec.py +++ b/tests/test_rendered_spec.py @@ -40,7 +40,7 @@ def test_get_and_update_rendered_spec_fail(self, _get_spec): self.assertEqual(_get_spec.call_count, 1) self.assertFalse(updated) - @patch("polyapi.rendered_spec.requests.post") + @patch("polyapi.http_client.post") @patch("polyapi.rendered_spec._get_spec") def test_get_and_update_rendered_spec_success(self, _get_spec, post): """ pass in a bad id to update and make sure it returns False diff --git a/tests/test_tabi.py b/tests/test_tabi.py index 0241191..3119d6f 100644 --- a/tests/test_tabi.py +++ b/tests/test_tabi.py @@ -682,7 +682,7 @@ def test_execute_query_uses_absolute_url_and_auth_header(self): return_value=("test-api-key", "https://na1.polyapi.io"), ), patch( - "polyapi.poly_tables.requests.post", return_value=response + "polyapi.http_client.post", return_value=response ) as post_mock, ): result = execute_query("table-id-123", "select", {"where": {"id": "abc"}}) From d2d60b3b644259e419444491e32dfcd46bf90b68 Mon Sep 17 00:00:00 2001 From: harshi922 Date: Fri, 20 Feb 2026 12:44:42 -0500 Subject: [PATCH 17/17] bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 491e3d6..d651f61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "polyapi-python" -version = "0.3.13" +version = "0.3.14.dev1" description = "The Python Client for PolyAPI, the IPaaS by Developers for Developers" authors = [{ name = "Dan Fellin", email = "dan@polyapi.io" }] dependencies = [