polyapi-python 0.3.19.dev4__py3-none-any.whl → 0.3.20__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polyapi/__init__.py +18 -7
- polyapi/ai_function.py +96 -0
- polyapi/generate.py +13 -2
- polyapi/http_client.py +365 -34
- polyapi/typedefs.py +1 -1
- polyapi/utils.py +22 -4
- {polyapi_python-0.3.19.dev4.dist-info → polyapi_python-0.3.20.dist-info}/METADATA +34 -1
- {polyapi_python-0.3.19.dev4.dist-info → polyapi_python-0.3.20.dist-info}/RECORD +11 -10
- {polyapi_python-0.3.19.dev4.dist-info → polyapi_python-0.3.20.dist-info}/WHEEL +1 -1
- {polyapi_python-0.3.19.dev4.dist-info → polyapi_python-0.3.20.dist-info}/licenses/LICENSE +0 -0
- {polyapi_python-0.3.19.dev4.dist-info → polyapi_python-0.3.20.dist-info}/top_level.txt +0 -0
polyapi/__init__.py
CHANGED
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
import copy
|
|
2
|
+
import importlib
|
|
2
3
|
import os
|
|
3
|
-
import sys
|
|
4
4
|
from contextvars import ContextVar, Token
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
-
from
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, overload
|
|
7
8
|
|
|
8
9
|
import truststore
|
|
9
10
|
from typing_extensions import TypedDict
|
|
10
11
|
|
|
11
|
-
from .cli_constants import CLI_COMMANDS
|
|
12
|
-
|
|
13
12
|
truststore.inject_into_ssl()
|
|
14
13
|
|
|
15
14
|
__all__ = ["poly"]
|
|
16
15
|
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
poly: ModuleType
|
|
18
|
+
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
def _load_generated_poly() -> ModuleType:
|
|
19
21
|
currdir = os.path.dirname(os.path.abspath(__file__))
|
|
20
22
|
if not os.path.isdir(os.path.join(currdir, "poly")):
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
raise ModuleNotFoundError(
|
|
24
|
+
"No 'poly' found. Please run 'python3 -m polyapi generate' "
|
|
25
|
+
"to generate the 'poly' library for your tenant."
|
|
26
|
+
)
|
|
27
|
+
return importlib.import_module(".poly", __name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def __getattr__(name: str) -> ModuleType:
|
|
31
|
+
if name == "poly":
|
|
32
|
+
return _load_generated_poly()
|
|
33
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
23
34
|
|
|
24
35
|
|
|
25
36
|
class PolyCustomDict(TypedDict, total=False):
|
polyapi/ai_function.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Tuple, cast
|
|
2
|
+
|
|
3
|
+
from polyapi.typedefs import PropertySpecification, PropertyType
|
|
4
|
+
from polyapi.utils import add_type_import_path, parse_arguments, get_type_and_def, return_type_already_defined_in_args, rewrite_arg_name
|
|
5
|
+
|
|
6
|
+
AI_DEFS_TEMPLATE = """
|
|
7
|
+
from typing import List, Dict, Any, TypedDict, Callable
|
|
8
|
+
{args_def}
|
|
9
|
+
{return_type_def}
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
AI_FUNCTION_TEMPLATE = """
|
|
13
|
+
def {function_name}(
|
|
14
|
+
{args}
|
|
15
|
+
) -> {return_type_name}:
|
|
16
|
+
\"""{function_description}
|
|
17
|
+
|
|
18
|
+
Function ID: {function_id}
|
|
19
|
+
\"""
|
|
20
|
+
resp = execute("{function_type}", "{function_id}", {data})
|
|
21
|
+
try:
|
|
22
|
+
return {return_action}
|
|
23
|
+
except:
|
|
24
|
+
return resp.text # type: ignore # fallback for debugging
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def {function_name}_async(
|
|
28
|
+
{args}
|
|
29
|
+
) -> {return_type_name}:
|
|
30
|
+
\"""{function_description}
|
|
31
|
+
|
|
32
|
+
Function ID: {function_id}
|
|
33
|
+
\"""
|
|
34
|
+
resp = await execute_async("{function_type}", "{function_id}", {data})
|
|
35
|
+
try:
|
|
36
|
+
return {return_action}
|
|
37
|
+
except:
|
|
38
|
+
return resp.text # type: ignore # fallback for debugging
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def render_ai_function(
|
|
45
|
+
function_type: str,
|
|
46
|
+
function_name: str,
|
|
47
|
+
function_id: str,
|
|
48
|
+
function_description: str,
|
|
49
|
+
arguments: List[PropertySpecification],
|
|
50
|
+
return_type: Dict[str, Any],
|
|
51
|
+
) -> Tuple[str, str]:
|
|
52
|
+
arg_names = [a["name"] for a in arguments]
|
|
53
|
+
args, args_def = parse_arguments(function_name, arguments)
|
|
54
|
+
return_type_name, return_type_def = get_type_and_def(cast(PropertyType, return_type), "ReturnType")
|
|
55
|
+
|
|
56
|
+
if return_type_def and return_type_already_defined_in_args(return_type_name, args_def):
|
|
57
|
+
return_type_def = ""
|
|
58
|
+
|
|
59
|
+
data = "{" + ", ".join([f"'{arg}': {rewrite_arg_name(arg)}" for arg in arg_names]) + "}"
|
|
60
|
+
func_type_defs = AI_DEFS_TEMPLATE.format(
|
|
61
|
+
args_def=args_def,
|
|
62
|
+
return_type_def=return_type_def,
|
|
63
|
+
)
|
|
64
|
+
func_str = AI_FUNCTION_TEMPLATE.format(
|
|
65
|
+
return_type_name=_normalize_return_type_for_annotation(function_name, return_type_name),
|
|
66
|
+
function_type="ai",
|
|
67
|
+
function_name=function_name,
|
|
68
|
+
function_id=function_id,
|
|
69
|
+
function_description=function_description.replace('"', "'"),
|
|
70
|
+
args=args,
|
|
71
|
+
return_action=_get_ai_return_action(return_type_name),
|
|
72
|
+
data=data,
|
|
73
|
+
)
|
|
74
|
+
return func_str, func_type_defs
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _normalize_return_type_for_annotation(function_name: str, return_type_name: str) -> str:
|
|
78
|
+
if return_type_name == "ReturnType":
|
|
79
|
+
return "ReturnType"
|
|
80
|
+
return add_type_import_path(function_name, return_type_name)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _get_ai_return_action(return_type_name: str) -> str:
|
|
84
|
+
normalized_type = return_type_name.replace(" ", "")
|
|
85
|
+
|
|
86
|
+
if normalized_type in {"str", "Optional[str]"}:
|
|
87
|
+
return_action = "resp.text"
|
|
88
|
+
elif "|" in normalized_type:
|
|
89
|
+
union_parts = {part for part in normalized_type.split("|") if part}
|
|
90
|
+
if union_parts == {"str", "None"}:
|
|
91
|
+
return_action = "resp.text"
|
|
92
|
+
else:
|
|
93
|
+
return_action = "resp.json()"
|
|
94
|
+
else:
|
|
95
|
+
return_action = "resp.json()"
|
|
96
|
+
return return_action
|
polyapi/generate.py
CHANGED
|
@@ -17,6 +17,7 @@ from .webhook import render_webhook_handle
|
|
|
17
17
|
from .typedefs import PropertySpecification, SchemaSpecDto, SpecificationDto, VariableSpecDto, TableSpecDto
|
|
18
18
|
from .api import render_api_function
|
|
19
19
|
from .server import render_server_function
|
|
20
|
+
from .ai_function import render_ai_function
|
|
20
21
|
from .utils import add_import_to_init, get_auth_headers, init_the_init, print_green, to_func_namespace, to_type_module_alias
|
|
21
22
|
from .variables import generate_variables
|
|
22
23
|
from .poly_tables import generate_tables
|
|
@@ -29,6 +30,7 @@ SUPPORTED_FUNCTION_TYPES = {
|
|
|
29
30
|
"customFunction", # client function - this is badly named in /specs atm
|
|
30
31
|
"serverFunction",
|
|
31
32
|
"webhookHandle",
|
|
33
|
+
"aiFunction",
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
SUPPORTED_TYPES = SUPPORTED_FUNCTION_TYPES | {"serverVariable", "schema", "snippet", "table"}
|
|
@@ -51,7 +53,7 @@ def get_specs(contexts: Optional[List[str]] = None, names: Optional[List[str]] =
|
|
|
51
53
|
params: Any = {"noTypes": str(no_types).lower()}
|
|
52
54
|
|
|
53
55
|
if contexts:
|
|
54
|
-
params["contexts"] = contexts
|
|
56
|
+
params["contexts"] = ",".join(contexts)
|
|
55
57
|
|
|
56
58
|
if names:
|
|
57
59
|
params["names"] = names
|
|
@@ -98,7 +100,7 @@ def resolve_poly_refs(obj, schema_index):
|
|
|
98
100
|
def replace_poly_refs_in_functions(specs: List[SpecificationDto], schema_index):
|
|
99
101
|
spec_idxs_to_remove = []
|
|
100
102
|
for idx, spec in enumerate(specs):
|
|
101
|
-
if spec.get("type") in ("apiFunction", "customFunction", "serverFunction"):
|
|
103
|
+
if spec.get("type") in ("apiFunction", "customFunction", "serverFunction", "aiFunction"):
|
|
102
104
|
func = spec.get("function")
|
|
103
105
|
if func:
|
|
104
106
|
try:
|
|
@@ -501,6 +503,15 @@ def render_spec(spec: SpecificationDto) -> Tuple[str, str]:
|
|
|
501
503
|
arguments,
|
|
502
504
|
return_type,
|
|
503
505
|
)
|
|
506
|
+
elif function_type == "aiFunction":
|
|
507
|
+
func_str, func_type_defs = render_ai_function(
|
|
508
|
+
function_type,
|
|
509
|
+
function_name,
|
|
510
|
+
function_id,
|
|
511
|
+
function_description,
|
|
512
|
+
arguments,
|
|
513
|
+
return_type,
|
|
514
|
+
)
|
|
504
515
|
|
|
505
516
|
if X_POLY_REF_WARNING in func_type_defs:
|
|
506
517
|
# this indicates that jsonschema_gentypes has detected an x-poly-ref
|
polyapi/http_client.py
CHANGED
|
@@ -1,83 +1,414 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
2
5
|
import httpx
|
|
3
6
|
|
|
7
|
+
logger = logging.getLogger("poly")
|
|
8
|
+
|
|
9
|
+
# Connx pool + timeout defaults for the shared clients.
|
|
10
|
+
# All overridable via POLY_HTTP_* env vars so operators can tune limits or
|
|
11
|
+
# opt into timeouts without editing callers. Empty / "none" parses to None (unbounded).
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _env_opt_int(name: str, default: "int | None") -> "int | None":
|
|
15
|
+
raw = os.environ.get(name)
|
|
16
|
+
if raw is None:
|
|
17
|
+
return default
|
|
18
|
+
raw = raw.strip()
|
|
19
|
+
return None if raw == "" or raw.lower() in ("none", "null") else int(raw)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _env_opt_float(name: str, default: "float | None") -> "float | None":
|
|
23
|
+
raw = os.environ.get(name)
|
|
24
|
+
if raw is None:
|
|
25
|
+
return default
|
|
26
|
+
raw = raw.strip()
|
|
27
|
+
return None if raw == "" or raw.lower() in ("none", "null") else float(raw)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_limits() -> httpx.Limits:
|
|
31
|
+
return httpx.Limits(
|
|
32
|
+
max_connections=_env_opt_int("POLY_HTTP_MAX_CONNECTIONS", 200),
|
|
33
|
+
max_keepalive_connections=_env_opt_int("POLY_HTTP_MAX_KEEPALIVE_CONNECTIONS", None),
|
|
34
|
+
keepalive_expiry=_env_opt_float("POLY_HTTP_KEEPALIVE_EXPIRY", 30.0),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_timeout() -> httpx.Timeout:
|
|
39
|
+
# Default to the SDK's original unbounded timeout; bound per-phase only via env.
|
|
40
|
+
return httpx.Timeout(
|
|
41
|
+
connect=_env_opt_float("POLY_HTTP_CONNECT_TIMEOUT", None),
|
|
42
|
+
read=_env_opt_float("POLY_HTTP_READ_TIMEOUT", None),
|
|
43
|
+
write=_env_opt_float("POLY_HTTP_WRITE_TIMEOUT", None),
|
|
44
|
+
pool=_env_opt_float("POLY_HTTP_POOL_TIMEOUT", None),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _close_deadline() -> "float | None":
|
|
49
|
+
# Bound pool teardown so one wedged socket cannot hang invocation exit.
|
|
50
|
+
# None (POLY_HTTP_CLOSE_TIMEOUT=none) restores the old unbounded wait.
|
|
51
|
+
return _env_opt_float("POLY_HTTP_CLOSE_TIMEOUT", 5.0)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Import-time snapshot for reference/config surface. Clients re-read env at creation
|
|
55
|
+
DEFAULT_LIMITS = _build_limits()
|
|
56
|
+
DEFAULT_TIMEOUT = _build_timeout()
|
|
57
|
+
# PID that owns the clients below; a fork resets it
|
|
58
|
+
_owner_pid: int = os.getpid()
|
|
4
59
|
_sync_client: httpx.Client | None = None
|
|
5
|
-
|
|
6
|
-
|
|
60
|
+
# Coordinate lazy creation, active requests, and shutdown of _sync_client.
|
|
61
|
+
_sync_client_lock = threading.Lock()
|
|
62
|
+
_sync_client_condition = threading.Condition(_sync_client_lock)
|
|
63
|
+
_sync_inflight = 0
|
|
64
|
+
_sync_closing = False
|
|
65
|
+
# One async client per event loop.
|
|
66
|
+
_async_clients: "dict[asyncio.AbstractEventLoop, httpx.AsyncClient]" = {}
|
|
67
|
+
# In-flight request count per async client, so close_async can drain before aclose.
|
|
68
|
+
_async_inflight: "dict[httpx.AsyncClient, int]" = {}
|
|
69
|
+
# Event loops whose close() already wrapped
|
|
70
|
+
_hooked_loops: "set[asyncio.AbstractEventLoop]" = set()
|
|
71
|
+
# Shared close operation per event loop. New requests wait for it so they do not
|
|
72
|
+
# race a replacement client into the pool mid-close.
|
|
73
|
+
_closing_loops: "dict[asyncio.AbstractEventLoop, asyncio.Task[None]]" = {}
|
|
7
74
|
|
|
8
75
|
|
|
9
|
-
def
|
|
76
|
+
def _after_fork_in_child() -> None:
|
|
77
|
+
"""Reset all shared client state in a forked child
|
|
78
|
+
"""
|
|
79
|
+
global _owner_pid, _sync_client, _sync_client_lock, _sync_client_condition
|
|
80
|
+
global _sync_inflight, _sync_closing
|
|
81
|
+
_sync_client_lock = threading.Lock()
|
|
82
|
+
_sync_client_condition = threading.Condition(_sync_client_lock)
|
|
83
|
+
_sync_client = None
|
|
84
|
+
_sync_inflight = 0
|
|
85
|
+
_sync_closing = False
|
|
86
|
+
_async_clients.clear()
|
|
87
|
+
_async_inflight.clear()
|
|
88
|
+
_hooked_loops.clear()
|
|
89
|
+
_closing_loops.clear()
|
|
90
|
+
_owner_pid = os.getpid()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if hasattr(os, "register_at_fork"):
|
|
94
|
+
os.register_at_fork(after_in_child=_after_fork_in_child)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _reset_if_forked() -> None:
|
|
98
|
+
"""Drop clients inherited across os.fork() so childisolated
|
|
99
|
+
Clear not close the inherited refs - child makes its own."""
|
|
100
|
+
global _owner_pid, _sync_client, _sync_client_lock, _sync_client_condition
|
|
101
|
+
global _sync_inflight, _sync_closing
|
|
102
|
+
current_pid = os.getpid()
|
|
103
|
+
if current_pid != _owner_pid:
|
|
104
|
+
_sync_client_lock = threading.Lock()
|
|
105
|
+
_sync_client_condition = threading.Condition(_sync_client_lock)
|
|
106
|
+
_sync_client = None
|
|
107
|
+
_sync_inflight = 0
|
|
108
|
+
_sync_closing = False
|
|
109
|
+
_async_clients.clear()
|
|
110
|
+
_async_inflight.clear()
|
|
111
|
+
_hooked_loops.clear()
|
|
112
|
+
_closing_loops.clear()
|
|
113
|
+
_owner_pid = current_pid
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _get_or_create_sync_client() -> httpx.Client:
|
|
10
117
|
global _sync_client
|
|
11
118
|
if _sync_client is None:
|
|
12
|
-
_sync_client = httpx.Client(timeout=
|
|
119
|
+
_sync_client = httpx.Client(limits=_build_limits(), timeout=_build_timeout())
|
|
13
120
|
return _sync_client
|
|
14
121
|
|
|
15
122
|
|
|
123
|
+
def _get_sync_client() -> httpx.Client:
|
|
124
|
+
_reset_if_forked()
|
|
125
|
+
with _sync_client_condition:
|
|
126
|
+
while _sync_closing:
|
|
127
|
+
_sync_client_condition.wait()
|
|
128
|
+
return _get_or_create_sync_client()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _dispatch_sync(method_name: str, *args, **kwargs) -> httpx.Response:
|
|
132
|
+
global _sync_inflight
|
|
133
|
+
_reset_if_forked()
|
|
134
|
+
with _sync_client_condition:
|
|
135
|
+
while _sync_closing:
|
|
136
|
+
_sync_client_condition.wait()
|
|
137
|
+
client = _get_or_create_sync_client()
|
|
138
|
+
_sync_inflight += 1
|
|
139
|
+
try:
|
|
140
|
+
return getattr(client, method_name)(*args, **kwargs)
|
|
141
|
+
finally:
|
|
142
|
+
with _sync_client_condition:
|
|
143
|
+
_sync_inflight -= 1
|
|
144
|
+
if _sync_inflight == 0:
|
|
145
|
+
_sync_client_condition.notify_all()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _retire_async_client(
|
|
149
|
+
client: httpx.AsyncClient | None,
|
|
150
|
+
loop: asyncio.AbstractEventLoop | None,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Best-effort close of an async client whose event loop is being dumped.
|
|
153
|
+
"""
|
|
154
|
+
if client is None:
|
|
155
|
+
return
|
|
156
|
+
try:
|
|
157
|
+
if loop is not None and loop.is_running():
|
|
158
|
+
loop.call_soon_threadsafe(lambda: loop.create_task(client.aclose()))
|
|
159
|
+
logger.debug(f"Scheduled aclose of async client id={id(client)} pid={os.getpid()} loop={id(loop)}")
|
|
160
|
+
else:
|
|
161
|
+
loop_closed = None if loop is None else loop.is_closed()
|
|
162
|
+
logger.debug(f"Dropping async client id={id(client)} pid={os.getpid()} loop={id(loop)} loop_closed={loop_closed}")
|
|
163
|
+
except Exception:
|
|
164
|
+
logger.warning(f"Failed to retire async client id={id(client)} pid={os.getpid()} loop={id(loop)}", exc_info=True)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _sweep_dead_loops() -> None:
|
|
168
|
+
"""Wipe clients whose event loop has been closed.
|
|
169
|
+
"""
|
|
170
|
+
for loop in list(_async_clients):
|
|
171
|
+
if loop.is_closed():
|
|
172
|
+
_retire_async_client(_async_clients.pop(loop, None), loop)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _abandon_close_task(
|
|
176
|
+
close_task: "asyncio.Future[None]",
|
|
177
|
+
client: httpx.AsyncClient,
|
|
178
|
+
loop: asyncio.AbstractEventLoop,
|
|
179
|
+
deadline: "float | None",
|
|
180
|
+
context: str = "",
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Detach a pool close that blew its deadline.
|
|
183
|
+
|
|
184
|
+
The task is cancelled and dropped rather than awaited: waiting for an
|
|
185
|
+
unresponsive pool to unwind is the stall the deadline exists to prevent.
|
|
186
|
+
Cancelling is what keeps loop shutdown bounded too, since asyncio.run()
|
|
187
|
+
tears down by cancelling every task and gathering them; a cancelled close
|
|
188
|
+
completes there instead of blocking. Whatever stays open is released when
|
|
189
|
+
the coroutine is collected, and by the OS at exit.
|
|
190
|
+
"""
|
|
191
|
+
def _swallow(finished: "asyncio.Future[None]") -> None:
|
|
192
|
+
# Retrieve the result so a late failure is not reported as an
|
|
193
|
+
# unretrieved task exception.
|
|
194
|
+
if finished.cancelled():
|
|
195
|
+
return
|
|
196
|
+
error = finished.exception()
|
|
197
|
+
if error is not None:
|
|
198
|
+
logger.debug(
|
|
199
|
+
f"Abandoned aclose later failed with {error!r} id={id(client)} "
|
|
200
|
+
f"pid={os.getpid()} loop={id(loop)}")
|
|
201
|
+
|
|
202
|
+
close_task.add_done_callback(_swallow)
|
|
203
|
+
close_task.cancel()
|
|
204
|
+
logger.warning(
|
|
205
|
+
f"aclose exceeded {deadline}s deadline; abandoning pool "
|
|
206
|
+
f"id={id(client)} pid={os.getpid()} loop={id(loop)}{context}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _register_loop_close_hook(loop: asyncio.AbstractEventLoop) -> None:
|
|
210
|
+
"""aclose the loop's async client right before the loop is torn down.
|
|
211
|
+
"""
|
|
212
|
+
if loop in _hooked_loops:
|
|
213
|
+
return
|
|
214
|
+
original_close = loop.close
|
|
215
|
+
|
|
216
|
+
def _close_with_cleanup(*args, **kwargs):
|
|
217
|
+
_hooked_loops.discard(loop)
|
|
218
|
+
cached = _async_clients.pop(loop, None)
|
|
219
|
+
if cached is not None and not loop.is_closed():
|
|
220
|
+
deadline = _close_deadline()
|
|
221
|
+
try:
|
|
222
|
+
# asyncio.wait returns at the deadline without cancelling, so
|
|
223
|
+
# loop.close() proceeds even if the pool ignores cancellation.
|
|
224
|
+
close_task = loop.create_task(cached.aclose())
|
|
225
|
+
loop.run_until_complete(
|
|
226
|
+
asyncio.wait({close_task}, timeout=deadline))
|
|
227
|
+
if close_task.done():
|
|
228
|
+
# Surface real aclose failures, including a TimeoutError
|
|
229
|
+
# raised by aclose itself, rather than treating them as
|
|
230
|
+
# our deadline.
|
|
231
|
+
close_task.result()
|
|
232
|
+
else:
|
|
233
|
+
_abandon_close_task(
|
|
234
|
+
close_task, cached, loop, deadline, " on teardown")
|
|
235
|
+
except Exception:
|
|
236
|
+
logger.warning(
|
|
237
|
+
f"Failed to aclose async client id={id(cached)} pid={os.getpid()} "
|
|
238
|
+
f"loop={id(loop)} on teardown", exc_info=True)
|
|
239
|
+
return original_close(*args, **kwargs)
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
loop.close = _close_with_cleanup # type: ignore[method-assign]
|
|
243
|
+
_hooked_loops.add(loop)
|
|
244
|
+
except (AttributeError, TypeError):
|
|
245
|
+
logger.warning(
|
|
246
|
+
f"Could not hook loop.close pid={os.getpid()} loop={id(loop)}; "
|
|
247
|
+
"await close_async() before closing this event loop"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
16
251
|
def _get_async_client() -> httpx.AsyncClient:
|
|
17
|
-
|
|
252
|
+
_reset_if_forked()
|
|
18
253
|
current_loop = asyncio.get_running_loop()
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
254
|
+
client = _async_clients.get(current_loop)
|
|
255
|
+
if client is None:
|
|
256
|
+
_sweep_dead_loops()
|
|
257
|
+
limits = _build_limits()
|
|
258
|
+
client = httpx.AsyncClient(limits=limits, timeout=_build_timeout())
|
|
259
|
+
_async_clients[current_loop] = client
|
|
260
|
+
_register_loop_close_hook(current_loop)
|
|
261
|
+
logger.debug(
|
|
262
|
+
f"Created async client id={id(client)} pid={os.getpid()} loop={id(current_loop)} "
|
|
263
|
+
f"max_connections={limits.max_connections} "
|
|
264
|
+
f"max_keepalive={limits.max_keepalive_connections} "
|
|
265
|
+
f"keepalive_expiry={limits.keepalive_expiry}"
|
|
266
|
+
)
|
|
267
|
+
return client
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
async def _dispatch_async(method_name: str, *args, **kwargs) -> httpx.Response:
|
|
271
|
+
# If a shutdown is closing this loop's client, wait so a replacement is not
|
|
272
|
+
# published mid-close. The shutdown caller owns any close error.
|
|
273
|
+
closing = _closing_loops.get(asyncio.get_running_loop())
|
|
274
|
+
if closing is not None:
|
|
275
|
+
try:
|
|
276
|
+
await asyncio.shield(closing)
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
# Track in-flight requests per client so close_async can drain before aclose.
|
|
280
|
+
client = _get_async_client()
|
|
281
|
+
_async_inflight[client] = _async_inflight.get(client, 0) + 1
|
|
282
|
+
try:
|
|
283
|
+
return await getattr(client, method_name)(*args, **kwargs)
|
|
284
|
+
finally:
|
|
285
|
+
remaining = _async_inflight.get(client, 1) - 1
|
|
286
|
+
if remaining <= 0:
|
|
287
|
+
_async_inflight.pop(client, None)
|
|
288
|
+
else:
|
|
289
|
+
_async_inflight[client] = remaining
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
async def _drain_inflight(client: httpx.AsyncClient) -> None:
|
|
293
|
+
# Preserve the SDK's unbounded request behavior during shutdown. The shared
|
|
294
|
+
# close operation retains ownership until every active request finishes.
|
|
295
|
+
while _async_inflight.get(client, 0) > 0:
|
|
296
|
+
await asyncio.sleep(0.05)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def _wait_for_close_task(close_task: "asyncio.Task[None]") -> None:
|
|
300
|
+
"""Wait for shared cleanup before propagating caller cancellation."""
|
|
301
|
+
caller_cancelled = False
|
|
302
|
+
while not close_task.done():
|
|
303
|
+
try:
|
|
304
|
+
await asyncio.shield(close_task)
|
|
305
|
+
except asyncio.CancelledError:
|
|
306
|
+
caller_cancelled = True
|
|
307
|
+
close_task.result()
|
|
308
|
+
if caller_cancelled:
|
|
309
|
+
raise asyncio.CancelledError()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
async def _close_clients_for_loop(
|
|
313
|
+
current_loop: asyncio.AbstractEventLoop,
|
|
314
|
+
) -> None:
|
|
315
|
+
"""Close shared clients while retaining ownership through completion."""
|
|
316
|
+
try:
|
|
317
|
+
await asyncio.to_thread(close)
|
|
318
|
+
client = _async_clients.get(current_loop)
|
|
319
|
+
if client is None:
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
await _drain_inflight(client)
|
|
323
|
+
deadline = _close_deadline()
|
|
324
|
+
close_task = asyncio.ensure_future(client.aclose())
|
|
325
|
+
try:
|
|
326
|
+
# asyncio.wait returns at the deadline without cancelling the
|
|
327
|
+
# close, so the bound holds even when the pool never processes
|
|
328
|
+
# cancellation. wait_for would block on that unwind instead.
|
|
329
|
+
await asyncio.wait({close_task}, timeout=deadline)
|
|
330
|
+
if close_task.done():
|
|
331
|
+
# Real aclose errors, including a TimeoutError raised by aclose
|
|
332
|
+
# itself, still propagate to the close_async caller.
|
|
333
|
+
close_task.result()
|
|
334
|
+
else:
|
|
335
|
+
_abandon_close_task(close_task, client, current_loop, deadline)
|
|
336
|
+
finally:
|
|
337
|
+
if _async_clients.get(current_loop) is client:
|
|
338
|
+
_async_clients.pop(current_loop, None)
|
|
339
|
+
_async_inflight.pop(client, None)
|
|
340
|
+
finally:
|
|
341
|
+
if _closing_loops.get(current_loop) is asyncio.current_task():
|
|
342
|
+
_closing_loops.pop(current_loop, None)
|
|
343
|
+
_sweep_dead_loops()
|
|
23
344
|
|
|
24
345
|
|
|
25
346
|
def post(url, **kwargs) -> httpx.Response:
|
|
26
|
-
return
|
|
347
|
+
return _dispatch_sync("post", url, **kwargs)
|
|
27
348
|
|
|
28
349
|
|
|
29
350
|
async def async_post(url, **kwargs) -> httpx.Response:
|
|
30
|
-
return await
|
|
351
|
+
return await _dispatch_async("post", url, **kwargs)
|
|
31
352
|
|
|
32
353
|
|
|
33
354
|
def get(url, **kwargs) -> httpx.Response:
|
|
34
|
-
return
|
|
355
|
+
return _dispatch_sync("get", url, **kwargs)
|
|
35
356
|
|
|
36
357
|
|
|
37
358
|
async def async_get(url, **kwargs) -> httpx.Response:
|
|
38
|
-
return await
|
|
359
|
+
return await _dispatch_async("get", url, **kwargs)
|
|
39
360
|
|
|
40
361
|
|
|
41
362
|
def patch(url, **kwargs) -> httpx.Response:
|
|
42
|
-
return
|
|
363
|
+
return _dispatch_sync("patch", url, **kwargs)
|
|
43
364
|
|
|
44
365
|
|
|
45
366
|
async def async_patch(url, **kwargs) -> httpx.Response:
|
|
46
|
-
return await
|
|
367
|
+
return await _dispatch_async("patch", url, **kwargs)
|
|
47
368
|
|
|
48
369
|
|
|
49
370
|
def delete(url, **kwargs) -> httpx.Response:
|
|
50
|
-
return
|
|
371
|
+
return _dispatch_sync("delete", url, **kwargs)
|
|
51
372
|
|
|
52
373
|
|
|
53
374
|
async def async_delete(url, **kwargs) -> httpx.Response:
|
|
54
|
-
return await
|
|
375
|
+
return await _dispatch_async("delete", url, **kwargs)
|
|
55
376
|
|
|
56
377
|
|
|
57
378
|
def request(method, url, **kwargs) -> httpx.Response:
|
|
58
|
-
return
|
|
379
|
+
return _dispatch_sync("request", method, url, **kwargs)
|
|
59
380
|
|
|
60
381
|
|
|
61
382
|
async def async_request(method, url, **kwargs) -> httpx.Response:
|
|
62
|
-
return await
|
|
383
|
+
return await _dispatch_async("request", method, url, **kwargs)
|
|
63
384
|
|
|
64
385
|
|
|
65
386
|
def close():
|
|
66
|
-
global _sync_client
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
387
|
+
global _sync_client, _sync_closing
|
|
388
|
+
_reset_if_forked()
|
|
389
|
+
with _sync_client_condition:
|
|
390
|
+
while _sync_closing:
|
|
391
|
+
_sync_client_condition.wait()
|
|
392
|
+
if _sync_client is None:
|
|
393
|
+
return
|
|
394
|
+
_sync_closing = True
|
|
395
|
+
while _sync_inflight > 0:
|
|
396
|
+
_sync_client_condition.wait()
|
|
397
|
+
client = _sync_client
|
|
398
|
+
try:
|
|
399
|
+
client.close()
|
|
400
|
+
finally:
|
|
401
|
+
with _sync_client_condition:
|
|
402
|
+
if _sync_client is client:
|
|
403
|
+
_sync_client = None
|
|
404
|
+
_sync_closing = False
|
|
405
|
+
_sync_client_condition.notify_all()
|
|
70
406
|
|
|
71
|
-
async def close_async():
|
|
72
|
-
global _async_client, _async_client_loop
|
|
73
|
-
close()
|
|
74
|
-
client = _async_client
|
|
75
|
-
client_loop = _async_client_loop
|
|
76
|
-
_async_client = None
|
|
77
|
-
_async_client_loop = None
|
|
78
|
-
if client is None:
|
|
79
|
-
return
|
|
80
407
|
|
|
408
|
+
async def close_async():
|
|
81
409
|
current_loop = asyncio.get_running_loop()
|
|
82
|
-
|
|
83
|
-
|
|
410
|
+
close_task = _closing_loops.get(current_loop)
|
|
411
|
+
if close_task is None:
|
|
412
|
+
close_task = asyncio.create_task(_close_clients_for_loop(current_loop))
|
|
413
|
+
_closing_loops[current_loop] = close_task
|
|
414
|
+
await _wait_for_close_task(close_task)
|
polyapi/typedefs.py
CHANGED
|
@@ -35,7 +35,7 @@ class SpecificationDto(TypedDict):
|
|
|
35
35
|
description: str
|
|
36
36
|
# function is none (or function key not present) if this is actually VariableSpecDto
|
|
37
37
|
function: NotRequired[FunctionSpecification | None]
|
|
38
|
-
type: Literal['apiFunction', 'customFunction', 'serverFunction', 'authFunction', 'webhookHandle', 'serverVariable', 'table']
|
|
38
|
+
type: Literal['apiFunction', 'customFunction', 'serverFunction', 'authFunction', 'webhookHandle', 'serverVariable', 'table', 'aiFunction']
|
|
39
39
|
code: NotRequired[str]
|
|
40
40
|
language: str
|
|
41
41
|
|
polyapi/utils.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import keyword
|
|
2
2
|
import re
|
|
3
3
|
import os
|
|
4
|
-
from urllib.parse import urlparse
|
|
4
|
+
from urllib.parse import unquote, urlparse
|
|
5
5
|
from typing import Tuple, List, Optional, cast
|
|
6
6
|
from colorama import Fore, Style
|
|
7
7
|
from polyapi.constants import BASIC_PYTHON_TYPES
|
|
@@ -299,11 +299,29 @@ def _maybe_add_fallback_schema_name(a: PropertySpecification):
|
|
|
299
299
|
# Handle cases where type might be missing
|
|
300
300
|
if not a.get("type"):
|
|
301
301
|
return
|
|
302
|
-
|
|
302
|
+
|
|
303
303
|
if a["type"].get("kind") == "object" and a["type"].get("schema"):
|
|
304
304
|
schema = a["type"].get("schema", {})
|
|
305
|
-
|
|
306
|
-
|
|
305
|
+
title = schema.get("title") or schema.get("name")
|
|
306
|
+
if not title and a.get("name"):
|
|
307
|
+
title = a["name"].title()
|
|
308
|
+
schema["title"] = title
|
|
309
|
+
|
|
310
|
+
reference = schema.get("$ref")
|
|
311
|
+
definitions = schema.get("definitions")
|
|
312
|
+
reference_prefix = "#/definitions/"
|
|
313
|
+
if not isinstance(title, str):
|
|
314
|
+
return
|
|
315
|
+
if not isinstance(reference, str) or not reference.startswith(reference_prefix):
|
|
316
|
+
return
|
|
317
|
+
if not isinstance(definitions, dict):
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
definition_name = unquote(reference[len(reference_prefix):])
|
|
321
|
+
definition_name = definition_name.replace("~1", "/").replace("~0", "~")
|
|
322
|
+
definition = definitions.get(definition_name)
|
|
323
|
+
if isinstance(definition, dict):
|
|
324
|
+
definition.setdefault("title", title)
|
|
307
325
|
|
|
308
326
|
|
|
309
327
|
def _clean_description(text: str) -> str:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: polyapi-python
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.20
|
|
4
4
|
Summary: The Python SDK for the integration platform PolyAPI
|
|
5
5
|
Author-email: PolyAPI <support@polyapi.io>
|
|
6
6
|
License: MIT License
|
|
@@ -84,6 +84,39 @@ POLY_API_KEY='your_key'
|
|
|
84
84
|
POLY_API_BASE_URL='your_server' # e.g. na1.polyapi.io
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
+
### HTTP Client Configuration
|
|
88
|
+
|
|
89
|
+
The SDK reuses one synchronous HTTP client per process and one asynchronous
|
|
90
|
+
client per event loop. New clients read these optional environment variables:
|
|
91
|
+
|
|
92
|
+
| Variable | Default | Description |
|
|
93
|
+
| --- | --- | --- |
|
|
94
|
+
| `POLY_HTTP_MAX_CONNECTIONS` | `200` | Maximum connections per client |
|
|
95
|
+
| `POLY_HTTP_MAX_KEEPALIVE_CONNECTIONS` | `none` | Maximum idle keep-alive connections per client |
|
|
96
|
+
| `POLY_HTTP_KEEPALIVE_EXPIRY` | `30` | Idle keep-alive expiry in seconds |
|
|
97
|
+
| `POLY_HTTP_CONNECT_TIMEOUT` | `none` | Connection timeout in seconds |
|
|
98
|
+
| `POLY_HTTP_READ_TIMEOUT` | `none` | Read timeout in seconds |
|
|
99
|
+
| `POLY_HTTP_WRITE_TIMEOUT` | `none` | Write timeout in seconds |
|
|
100
|
+
| `POLY_HTTP_POOL_TIMEOUT` | `none` | Connection-pool timeout in seconds |
|
|
101
|
+
| `POLY_HTTP_CLOSE_TIMEOUT` | `5` | Deadline in seconds for closing a client's pool |
|
|
102
|
+
|
|
103
|
+
An empty value, `none`, or `null` means unbounded. Limits are per client, so
|
|
104
|
+
processes with multiple event loops can create one configured pool per loop.
|
|
105
|
+
|
|
106
|
+
`POLY_HTTP_CLOSE_TIMEOUT` is read at shutdown rather than at client creation.
|
|
107
|
+
It bounds pool teardown so a single unresponsive socket cannot stall shutdown:
|
|
108
|
+
if the deadline passes, the SDK logs a warning, abandons the remaining
|
|
109
|
+
connections to the operating system, and retires the client so it is never
|
|
110
|
+
reused. Set it to `none` to wait indefinitely.
|
|
111
|
+
|
|
112
|
+
Call `polyapi.http_client.close()` during synchronous shutdown or await
|
|
113
|
+
`polyapi.http_client.close_async()` on each owning event loop during
|
|
114
|
+
asynchronous shutdown. The shutdown functions wait for active SDK requests
|
|
115
|
+
before closing their client. Clients on standard asyncio loops are closed
|
|
116
|
+
automatically when the loop closes. If a custom event-loop implementation
|
|
117
|
+
reports that its close hook could not be installed, explicitly await
|
|
118
|
+
`close_async()` before closing that loop.
|
|
119
|
+
|
|
87
120
|
### 3. Test
|
|
88
121
|
|
|
89
122
|
That's it! Now open up a test file and you can run some code like so:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
polyapi/__init__.py,sha256=
|
|
1
|
+
polyapi/__init__.py,sha256=oEijoOVghgeEbwqtWK_74QU38HBDt4THXHBbsortJ1w,5505
|
|
2
2
|
polyapi/__main__.py,sha256=V4zhAh_YGxno5f_KSrlkELxcuDh9bR3WSd0n-2r-qQQ,93
|
|
3
|
+
polyapi/ai_function.py,sha256=GvJKpBZFbI11y9JAKTHXMkztbMHZxNTfL37LLbGKmwI,3017
|
|
3
4
|
polyapi/api.py,sha256=CzqX99GekhQJVqAAWlL03hUenIi_5iTln9cWgQzz6Ko,2784
|
|
4
5
|
polyapi/auth.py,sha256=l0pGAoYvEWqa2TMsrM9GS8imXu3GsWvpn4eFPy1vksI,6195
|
|
5
6
|
polyapi/cli.py,sha256=V3586ElV1927u2AhQZAo_4UYKoJPqE8lz6b3_gJfMj0,11918
|
|
@@ -12,8 +13,8 @@ polyapi/error_handler.py,sha256=I_e0iz6VM23FLVQWJljxs2NGcl_OODbi43OcbnqBlp8,2398
|
|
|
12
13
|
polyapi/exceptions.py,sha256=Zh7i7eCUhDuXEdUYjatkLFTeZkrx1BJ1P5ePgbJ9eIY,89
|
|
13
14
|
polyapi/execute.py,sha256=4jLkQely6cM7pNN9pYpGZTbEis-DfwENi5Fl07xSiig,8966
|
|
14
15
|
polyapi/function_cli.py,sha256=gJE72lRvuWnGl6CG9GpqL78TOnUzxVe4kJMvg0PQixw,4493
|
|
15
|
-
polyapi/generate.py,sha256=
|
|
16
|
-
polyapi/http_client.py,sha256=
|
|
16
|
+
polyapi/generate.py,sha256=U8JQ_XR2gJI_eYNnmvXReILUxapj95QKoBronqMFkyE,22403
|
|
17
|
+
polyapi/http_client.py,sha256=5N-zWSsjdMCPnrICR45EJWtXtBNyMP42L6Q8_Bh7n1A,15198
|
|
17
18
|
polyapi/parser.py,sha256=t3jJI6g5F7Qg_tirPOzSkQ02VLkSVhnqs9YGapyHx2k,21901
|
|
18
19
|
polyapi/poly_schemas.py,sha256=JGMQbELPFw4V_0nA0o71WJKFTdxjCGoKqJvIid_Y-a4,10759
|
|
19
20
|
polyapi/poly_tables.py,sha256=SlP7ZwqS3nIji2uGR2VJQH6IdddZ832rRItcmoYdIBc,20293
|
|
@@ -23,12 +24,12 @@ polyapi/rendered_spec.py,sha256=J2Num7MXnW_mKAe2MSVIL1coWopNYZ-6nNNwQRfseV0,2262
|
|
|
23
24
|
polyapi/schema.py,sha256=SPO5GDvIE1BjRfJR89vJMEGCXERfEbhNXQm2A05UAOc,6959
|
|
24
25
|
polyapi/server.py,sha256=_14lP4_BmRYMBXwaqXVWmiSTHH3h7ecCHxSalM9UiSc,3049
|
|
25
26
|
polyapi/sync.py,sha256=t0Khh8B5GylxB7KWRP_xvkrvkuyVarSLCOvtmaz7lcc,6766
|
|
26
|
-
polyapi/typedefs.py,sha256=
|
|
27
|
-
polyapi/utils.py,sha256=
|
|
27
|
+
polyapi/typedefs.py,sha256=UeAdyYrgqxrvBRkjisK_xgzQmuTePPvOM74HKf1y0II,5656
|
|
28
|
+
polyapi/utils.py,sha256=LUqTkbb0b7y3gjsOU-WO1LVYaTMyExTGpYjKY8wVKg8,16300
|
|
28
29
|
polyapi/variables.py,sha256=hfSDPGQK6YphNCa9MqE0W88WIFfqCQWgpBDWkExMq-A,7582
|
|
29
30
|
polyapi/webhook.py,sha256=0ceLwHNjNd2Yx_8MX5jOIxiQ5Lwhy2pe81ProAuKon0,5108
|
|
30
|
-
polyapi_python-0.3.
|
|
31
|
-
polyapi_python-0.3.
|
|
32
|
-
polyapi_python-0.3.
|
|
33
|
-
polyapi_python-0.3.
|
|
34
|
-
polyapi_python-0.3.
|
|
31
|
+
polyapi_python-0.3.20.dist-info/licenses/LICENSE,sha256=6b_I7aPVp8JXhqQwdw7_B84Ca0S4JGjHj0sr_1VOdB4,1068
|
|
32
|
+
polyapi_python-0.3.20.dist-info/METADATA,sha256=dk9Wpal3miMVocn7pAkop1yBllU4pA1m3u9k3GtWCz0,7732
|
|
33
|
+
polyapi_python-0.3.20.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
34
|
+
polyapi_python-0.3.20.dist-info/top_level.txt,sha256=CEFllOnzowci_50RYJac-M54KD2IdAptFsayVVF_f04,8
|
|
35
|
+
polyapi_python-0.3.20.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|