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 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 typing import Any, Dict, Literal, Optional, overload
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
- if len(sys.argv) > 1 and sys.argv[1] not in CLI_COMMANDS:
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
- print("No 'poly' found. Please run 'python3 -m polyapi generate' to generate the 'poly' library for your tenant.")
22
- sys.exit(1)
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
- _async_client: httpx.AsyncClient | None = None
6
- _async_client_loop: asyncio.AbstractEventLoop | None = None
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 _get_sync_client() -> httpx.Client:
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=None)
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
- global _async_client, _async_client_loop
252
+ _reset_if_forked()
18
253
  current_loop = asyncio.get_running_loop()
19
- if _async_client is None or _async_client_loop is not current_loop:
20
- _async_client = httpx.AsyncClient(timeout=None)
21
- _async_client_loop = current_loop
22
- return _async_client
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 _get_sync_client().post(url, **kwargs)
347
+ return _dispatch_sync("post", url, **kwargs)
27
348
 
28
349
 
29
350
  async def async_post(url, **kwargs) -> httpx.Response:
30
- return await _get_async_client().post(url, **kwargs)
351
+ return await _dispatch_async("post", url, **kwargs)
31
352
 
32
353
 
33
354
  def get(url, **kwargs) -> httpx.Response:
34
- return _get_sync_client().get(url, **kwargs)
355
+ return _dispatch_sync("get", url, **kwargs)
35
356
 
36
357
 
37
358
  async def async_get(url, **kwargs) -> httpx.Response:
38
- return await _get_async_client().get(url, **kwargs)
359
+ return await _dispatch_async("get", url, **kwargs)
39
360
 
40
361
 
41
362
  def patch(url, **kwargs) -> httpx.Response:
42
- return _get_sync_client().patch(url, **kwargs)
363
+ return _dispatch_sync("patch", url, **kwargs)
43
364
 
44
365
 
45
366
  async def async_patch(url, **kwargs) -> httpx.Response:
46
- return await _get_async_client().patch(url, **kwargs)
367
+ return await _dispatch_async("patch", url, **kwargs)
47
368
 
48
369
 
49
370
  def delete(url, **kwargs) -> httpx.Response:
50
- return _get_sync_client().delete(url, **kwargs)
371
+ return _dispatch_sync("delete", url, **kwargs)
51
372
 
52
373
 
53
374
  async def async_delete(url, **kwargs) -> httpx.Response:
54
- return await _get_async_client().delete(url, **kwargs)
375
+ return await _dispatch_async("delete", url, **kwargs)
55
376
 
56
377
 
57
378
  def request(method, url, **kwargs) -> httpx.Response:
58
- return _get_sync_client().request(method, url, **kwargs)
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 _get_async_client().request(method, url, **kwargs)
383
+ return await _dispatch_async("request", method, url, **kwargs)
63
384
 
64
385
 
65
386
  def close():
66
- global _sync_client
67
- if _sync_client is not None:
68
- _sync_client.close()
69
- _sync_client = None
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
- if client_loop is current_loop:
83
- await client.aclose()
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
- if not schema.get("title") and not schema.get("name") and a.get("name"):
306
- schema["title"] = a["name"].title()
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.19.dev4
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=vV1N9xtIMtpi4o-2jAI3775LkzRbC9JUo87MAnW9zgo,5201
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=9B6DbrkBAPWpMVD92ifFYV2JaKNCduMQhqJ4Gm48qq0,22051
16
- polyapi/http_client.py,sha256=TcoFsIvRcNB9wWJT4YePPWFFY6lX90cdqi7C4jKbqqk,2259
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=mfll-KrngOW0wzbvDNiIDaDkFMwbsT-MY5y5hzWj9RE,5642
27
- polyapi/utils.py,sha256=dIkuIlGNsg1USiXNNkUnOtLVzie4mxBCBlTc0YsaXao,15594
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.19.dev4.dist-info/licenses/LICENSE,sha256=6b_I7aPVp8JXhqQwdw7_B84Ca0S4JGjHj0sr_1VOdB4,1068
31
- polyapi_python-0.3.19.dev4.dist-info/METADATA,sha256=E2Ot99-wAaduZDv9OUFxzH7cFH3m020ZGT3OcNXj9iM,5898
32
- polyapi_python-0.3.19.dev4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
33
- polyapi_python-0.3.19.dev4.dist-info/top_level.txt,sha256=CEFllOnzowci_50RYJac-M54KD2IdAptFsayVVF_f04,8
34
- polyapi_python-0.3.19.dev4.dist-info/RECORD,,
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5