polyapi-python 0.3.20.dev1__tar.gz → 0.3.20.dev3__tar.gz

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.
Files changed (59) hide show
  1. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/PKG-INFO +27 -1
  2. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/README.md +27 -1
  3. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/__init__.py +18 -7
  4. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/generate.py +1 -1
  5. polyapi_python-0.3.20.dev3/polyapi/http_client.py +350 -0
  6. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi_python.egg-info/PKG-INFO +27 -1
  7. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi_python.egg-info/SOURCES.txt +1 -0
  8. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/pyproject.toml +1 -1
  9. polyapi_python-0.3.20.dev3/tests/test_async_proof.py +1560 -0
  10. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_generate.py +21 -1
  11. polyapi_python-0.3.20.dev3/tests/test_generated_poly_boundary.py +47 -0
  12. polyapi_python-0.3.20.dev1/polyapi/http_client.py +0 -83
  13. polyapi_python-0.3.20.dev1/tests/test_async_proof.py +0 -498
  14. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/LICENSE +0 -0
  15. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/__main__.py +0 -0
  16. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/ai_function.py +0 -0
  17. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/api.py +0 -0
  18. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/auth.py +0 -0
  19. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/cli.py +0 -0
  20. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/cli_constants.py +0 -0
  21. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/client.py +0 -0
  22. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/config.py +0 -0
  23. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/constants.py +0 -0
  24. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/deployables.py +0 -0
  25. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/error_handler.py +0 -0
  26. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/exceptions.py +0 -0
  27. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/execute.py +0 -0
  28. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/function_cli.py +0 -0
  29. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/parser.py +0 -0
  30. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/poly_schemas.py +0 -0
  31. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/poly_tables.py +0 -0
  32. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/prepare.py +0 -0
  33. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/py.typed +0 -0
  34. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/rendered_spec.py +0 -0
  35. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/schema.py +0 -0
  36. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/server.py +0 -0
  37. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/sync.py +0 -0
  38. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/typedefs.py +0 -0
  39. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/utils.py +0 -0
  40. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/variables.py +0 -0
  41. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/webhook.py +0 -0
  42. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi_python.egg-info/dependency_links.txt +0 -0
  43. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi_python.egg-info/requires.txt +0 -0
  44. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi_python.egg-info/top_level.txt +0 -0
  45. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/setup.cfg +0 -0
  46. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_api.py +0 -0
  47. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_auth.py +0 -0
  48. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_cli.py +0 -0
  49. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_dependency_pins.py +0 -0
  50. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_deployables.py +0 -0
  51. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_parser.py +0 -0
  52. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_poly_custom.py +0 -0
  53. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_poly_schemas.py +0 -0
  54. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_rendered_spec.py +0 -0
  55. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_schema.py +0 -0
  56. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_server.py +0 -0
  57. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_tabi.py +0 -0
  58. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_utils.py +0 -0
  59. {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_variables.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polyapi-python
3
- Version: 0.3.20.dev1
3
+ Version: 0.3.20.dev3
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,32 @@ 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
+
102
+ An empty value, `none`, or `null` means unbounded. Limits are per client, so
103
+ processes with multiple event loops can create one configured pool per loop.
104
+
105
+ Call `polyapi.http_client.close()` during synchronous shutdown or await
106
+ `polyapi.http_client.close_async()` on each owning event loop during
107
+ asynchronous shutdown. The shutdown functions wait for active SDK requests
108
+ before closing their client. Clients on standard asyncio loops are closed
109
+ automatically when the loop closes. If a custom event-loop implementation
110
+ reports that its close hook could not be installed, explicitly await
111
+ `close_async()` before closing that loop.
112
+
87
113
  ### 3. Test
88
114
 
89
115
  That's it! Now open up a test file and you can run some code like so:
@@ -43,6 +43,32 @@ POLY_API_KEY='your_key'
43
43
  POLY_API_BASE_URL='your_server' # e.g. na1.polyapi.io
44
44
  ```
45
45
 
46
+ ### HTTP Client Configuration
47
+
48
+ The SDK reuses one synchronous HTTP client per process and one asynchronous
49
+ client per event loop. New clients read these optional environment variables:
50
+
51
+ | Variable | Default | Description |
52
+ | --- | --- | --- |
53
+ | `POLY_HTTP_MAX_CONNECTIONS` | `200` | Maximum connections per client |
54
+ | `POLY_HTTP_MAX_KEEPALIVE_CONNECTIONS` | `none` | Maximum idle keep-alive connections per client |
55
+ | `POLY_HTTP_KEEPALIVE_EXPIRY` | `30` | Idle keep-alive expiry in seconds |
56
+ | `POLY_HTTP_CONNECT_TIMEOUT` | `none` | Connection timeout in seconds |
57
+ | `POLY_HTTP_READ_TIMEOUT` | `none` | Read timeout in seconds |
58
+ | `POLY_HTTP_WRITE_TIMEOUT` | `none` | Write timeout in seconds |
59
+ | `POLY_HTTP_POOL_TIMEOUT` | `none` | Connection-pool timeout in seconds |
60
+
61
+ An empty value, `none`, or `null` means unbounded. Limits are per client, so
62
+ processes with multiple event loops can create one configured pool per loop.
63
+
64
+ Call `polyapi.http_client.close()` during synchronous shutdown or await
65
+ `polyapi.http_client.close_async()` on each owning event loop during
66
+ asynchronous shutdown. The shutdown functions wait for active SDK requests
67
+ before closing their client. Clients on standard asyncio loops are closed
68
+ automatically when the loop closes. If a custom event-loop implementation
69
+ reports that its close hook could not be installed, explicitly await
70
+ `close_async()` before closing that loop.
71
+
46
72
  ### 3. Test
47
73
 
48
74
  That's it! Now open up a test file and you can run some code like so:
@@ -156,4 +182,4 @@ Of course all this is in addition to the changes passing through normal unittest
156
182
  ## Support
157
183
 
158
184
  If you run into any issues or want help getting started with this project, please contact support@polyapi.io
159
- .
185
+ .
@@ -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):
@@ -53,7 +53,7 @@ def get_specs(contexts: Optional[List[str]] = None, names: Optional[List[str]] =
53
53
  params: Any = {"noTypes": str(no_types).lower()}
54
54
 
55
55
  if contexts:
56
- params["contexts"] = contexts
56
+ params["contexts"] = ",".join(contexts)
57
57
 
58
58
  if names:
59
59
  params["names"] = names
@@ -0,0 +1,350 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import threading
5
+ import httpx
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
+ # Import-time snapshot for reference/config surface. Clients re-read env at creation
49
+ DEFAULT_LIMITS = _build_limits()
50
+ DEFAULT_TIMEOUT = _build_timeout()
51
+ # PID that owns the clients below; a fork resets it
52
+ _owner_pid: int = os.getpid()
53
+ _sync_client: httpx.Client | None = None
54
+ # Coordinate lazy creation, active requests, and shutdown of _sync_client.
55
+ _sync_client_lock = threading.Lock()
56
+ _sync_client_condition = threading.Condition(_sync_client_lock)
57
+ _sync_inflight = 0
58
+ _sync_closing = False
59
+ # One async client per event loop.
60
+ _async_clients: "dict[asyncio.AbstractEventLoop, httpx.AsyncClient]" = {}
61
+ # In-flight request count per async client, so close_async can drain before aclose.
62
+ _async_inflight: "dict[httpx.AsyncClient, int]" = {}
63
+ # Event loops whose close() already wrapped
64
+ _hooked_loops: "set[asyncio.AbstractEventLoop]" = set()
65
+ # Shared close operation per event loop. New requests wait for it so they do not
66
+ # race a replacement client into the pool mid-close.
67
+ _closing_loops: "dict[asyncio.AbstractEventLoop, asyncio.Task[None]]" = {}
68
+
69
+
70
+ def _after_fork_in_child() -> None:
71
+ """Reset all shared client state in a forked child
72
+ """
73
+ global _owner_pid, _sync_client, _sync_client_lock, _sync_client_condition
74
+ global _sync_inflight, _sync_closing
75
+ _sync_client_lock = threading.Lock()
76
+ _sync_client_condition = threading.Condition(_sync_client_lock)
77
+ _sync_client = None
78
+ _sync_inflight = 0
79
+ _sync_closing = False
80
+ _async_clients.clear()
81
+ _async_inflight.clear()
82
+ _hooked_loops.clear()
83
+ _closing_loops.clear()
84
+ _owner_pid = os.getpid()
85
+
86
+
87
+ if hasattr(os, "register_at_fork"):
88
+ os.register_at_fork(after_in_child=_after_fork_in_child)
89
+
90
+
91
+ def _reset_if_forked() -> None:
92
+ """Drop clients inherited across os.fork() so childisolated
93
+ Clear not close the inherited refs - child makes its own."""
94
+ global _owner_pid, _sync_client, _sync_client_lock, _sync_client_condition
95
+ global _sync_inflight, _sync_closing
96
+ current_pid = os.getpid()
97
+ if current_pid != _owner_pid:
98
+ _sync_client_lock = threading.Lock()
99
+ _sync_client_condition = threading.Condition(_sync_client_lock)
100
+ _sync_client = None
101
+ _sync_inflight = 0
102
+ _sync_closing = False
103
+ _async_clients.clear()
104
+ _async_inflight.clear()
105
+ _hooked_loops.clear()
106
+ _closing_loops.clear()
107
+ _owner_pid = current_pid
108
+
109
+
110
+ def _get_or_create_sync_client() -> httpx.Client:
111
+ global _sync_client
112
+ if _sync_client is None:
113
+ _sync_client = httpx.Client(limits=_build_limits(), timeout=_build_timeout())
114
+ return _sync_client
115
+
116
+
117
+ def _get_sync_client() -> httpx.Client:
118
+ _reset_if_forked()
119
+ with _sync_client_condition:
120
+ while _sync_closing:
121
+ _sync_client_condition.wait()
122
+ return _get_or_create_sync_client()
123
+
124
+
125
+ def _dispatch_sync(method_name: str, *args, **kwargs) -> httpx.Response:
126
+ global _sync_inflight
127
+ _reset_if_forked()
128
+ with _sync_client_condition:
129
+ while _sync_closing:
130
+ _sync_client_condition.wait()
131
+ client = _get_or_create_sync_client()
132
+ _sync_inflight += 1
133
+ try:
134
+ return getattr(client, method_name)(*args, **kwargs)
135
+ finally:
136
+ with _sync_client_condition:
137
+ _sync_inflight -= 1
138
+ if _sync_inflight == 0:
139
+ _sync_client_condition.notify_all()
140
+
141
+
142
+ def _retire_async_client(
143
+ client: httpx.AsyncClient | None,
144
+ loop: asyncio.AbstractEventLoop | None,
145
+ ) -> None:
146
+ """Best-effort close of an async client whose event loop is being dumped.
147
+ """
148
+ if client is None:
149
+ return
150
+ try:
151
+ if loop is not None and loop.is_running():
152
+ loop.call_soon_threadsafe(lambda: loop.create_task(client.aclose()))
153
+ logger.debug(f"Scheduled aclose of async client id={id(client)} pid={os.getpid()} loop={id(loop)}")
154
+ else:
155
+ loop_closed = None if loop is None else loop.is_closed()
156
+ logger.debug(f"Dropping async client id={id(client)} pid={os.getpid()} loop={id(loop)} loop_closed={loop_closed}")
157
+ except Exception:
158
+ logger.warning(f"Failed to retire async client id={id(client)} pid={os.getpid()} loop={id(loop)}", exc_info=True)
159
+
160
+
161
+ def _sweep_dead_loops() -> None:
162
+ """Wipe clients whose event loop has been closed.
163
+ """
164
+ for loop in list(_async_clients):
165
+ if loop.is_closed():
166
+ _retire_async_client(_async_clients.pop(loop, None), loop)
167
+
168
+
169
+ def _register_loop_close_hook(loop: asyncio.AbstractEventLoop) -> None:
170
+ """aclose the loop's async client right before the loop is torn down.
171
+ """
172
+ if loop in _hooked_loops:
173
+ return
174
+ original_close = loop.close
175
+
176
+ def _close_with_cleanup(*args, **kwargs):
177
+ _hooked_loops.discard(loop)
178
+ cached = _async_clients.pop(loop, None)
179
+ if cached is not None and not loop.is_closed():
180
+ try:
181
+ loop.run_until_complete(cached.aclose())
182
+ except Exception:
183
+ logger.warning(
184
+ f"Failed to aclose async client id={id(cached)} pid={os.getpid()} "
185
+ f"loop={id(loop)} on teardown", exc_info=True)
186
+ return original_close(*args, **kwargs)
187
+
188
+ try:
189
+ loop.close = _close_with_cleanup # type: ignore[method-assign]
190
+ _hooked_loops.add(loop)
191
+ except (AttributeError, TypeError):
192
+ logger.warning(
193
+ f"Could not hook loop.close pid={os.getpid()} loop={id(loop)}; "
194
+ "await close_async() before closing this event loop"
195
+ )
196
+
197
+
198
+ def _get_async_client() -> httpx.AsyncClient:
199
+ _reset_if_forked()
200
+ current_loop = asyncio.get_running_loop()
201
+ client = _async_clients.get(current_loop)
202
+ if client is None:
203
+ _sweep_dead_loops()
204
+ limits = _build_limits()
205
+ client = httpx.AsyncClient(limits=limits, timeout=_build_timeout())
206
+ _async_clients[current_loop] = client
207
+ _register_loop_close_hook(current_loop)
208
+ logger.debug(
209
+ f"Created async client id={id(client)} pid={os.getpid()} loop={id(current_loop)} "
210
+ f"max_connections={limits.max_connections} "
211
+ f"max_keepalive={limits.max_keepalive_connections} "
212
+ f"keepalive_expiry={limits.keepalive_expiry}"
213
+ )
214
+ return client
215
+
216
+
217
+ async def _dispatch_async(method_name: str, *args, **kwargs) -> httpx.Response:
218
+ # If a shutdown is closing this loop's client, wait so a replacement is not
219
+ # published mid-close. The shutdown caller owns any close error.
220
+ closing = _closing_loops.get(asyncio.get_running_loop())
221
+ if closing is not None:
222
+ try:
223
+ await asyncio.shield(closing)
224
+ except Exception:
225
+ pass
226
+ # Track in-flight requests per client so close_async can drain before aclose.
227
+ client = _get_async_client()
228
+ _async_inflight[client] = _async_inflight.get(client, 0) + 1
229
+ try:
230
+ return await getattr(client, method_name)(*args, **kwargs)
231
+ finally:
232
+ remaining = _async_inflight.get(client, 1) - 1
233
+ if remaining <= 0:
234
+ _async_inflight.pop(client, None)
235
+ else:
236
+ _async_inflight[client] = remaining
237
+
238
+
239
+ async def _drain_inflight(client: httpx.AsyncClient) -> None:
240
+ # Preserve the SDK's unbounded request behavior during shutdown. The shared
241
+ # close operation retains ownership until every active request finishes.
242
+ while _async_inflight.get(client, 0) > 0:
243
+ await asyncio.sleep(0.05)
244
+
245
+
246
+ async def _wait_for_close_task(close_task: "asyncio.Task[None]") -> None:
247
+ """Wait for shared cleanup before propagating caller cancellation."""
248
+ caller_cancelled = False
249
+ while not close_task.done():
250
+ try:
251
+ await asyncio.shield(close_task)
252
+ except asyncio.CancelledError:
253
+ caller_cancelled = True
254
+ close_task.result()
255
+ if caller_cancelled:
256
+ raise asyncio.CancelledError()
257
+
258
+
259
+ async def _close_clients_for_loop(
260
+ current_loop: asyncio.AbstractEventLoop,
261
+ ) -> None:
262
+ """Close shared clients while retaining ownership through completion."""
263
+ try:
264
+ await asyncio.to_thread(close)
265
+ client = _async_clients.get(current_loop)
266
+ if client is None:
267
+ return
268
+
269
+ await _drain_inflight(client)
270
+ try:
271
+ await client.aclose()
272
+ finally:
273
+ if _async_clients.get(current_loop) is client:
274
+ _async_clients.pop(current_loop, None)
275
+ _async_inflight.pop(client, None)
276
+ finally:
277
+ if _closing_loops.get(current_loop) is asyncio.current_task():
278
+ _closing_loops.pop(current_loop, None)
279
+ _sweep_dead_loops()
280
+
281
+
282
+ def post(url, **kwargs) -> httpx.Response:
283
+ return _dispatch_sync("post", url, **kwargs)
284
+
285
+
286
+ async def async_post(url, **kwargs) -> httpx.Response:
287
+ return await _dispatch_async("post", url, **kwargs)
288
+
289
+
290
+ def get(url, **kwargs) -> httpx.Response:
291
+ return _dispatch_sync("get", url, **kwargs)
292
+
293
+
294
+ async def async_get(url, **kwargs) -> httpx.Response:
295
+ return await _dispatch_async("get", url, **kwargs)
296
+
297
+
298
+ def patch(url, **kwargs) -> httpx.Response:
299
+ return _dispatch_sync("patch", url, **kwargs)
300
+
301
+
302
+ async def async_patch(url, **kwargs) -> httpx.Response:
303
+ return await _dispatch_async("patch", url, **kwargs)
304
+
305
+
306
+ def delete(url, **kwargs) -> httpx.Response:
307
+ return _dispatch_sync("delete", url, **kwargs)
308
+
309
+
310
+ async def async_delete(url, **kwargs) -> httpx.Response:
311
+ return await _dispatch_async("delete", url, **kwargs)
312
+
313
+
314
+ def request(method, url, **kwargs) -> httpx.Response:
315
+ return _dispatch_sync("request", method, url, **kwargs)
316
+
317
+
318
+ async def async_request(method, url, **kwargs) -> httpx.Response:
319
+ return await _dispatch_async("request", method, url, **kwargs)
320
+
321
+
322
+ def close():
323
+ global _sync_client, _sync_closing
324
+ _reset_if_forked()
325
+ with _sync_client_condition:
326
+ while _sync_closing:
327
+ _sync_client_condition.wait()
328
+ if _sync_client is None:
329
+ return
330
+ _sync_closing = True
331
+ while _sync_inflight > 0:
332
+ _sync_client_condition.wait()
333
+ client = _sync_client
334
+ try:
335
+ client.close()
336
+ finally:
337
+ with _sync_client_condition:
338
+ if _sync_client is client:
339
+ _sync_client = None
340
+ _sync_closing = False
341
+ _sync_client_condition.notify_all()
342
+
343
+
344
+ async def close_async():
345
+ current_loop = asyncio.get_running_loop()
346
+ close_task = _closing_loops.get(current_loop)
347
+ if close_task is None:
348
+ close_task = asyncio.create_task(_close_clients_for_loop(current_loop))
349
+ _closing_loops[current_loop] = close_task
350
+ await _wait_for_close_task(close_task)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polyapi-python
3
- Version: 0.3.20.dev1
3
+ Version: 0.3.20.dev3
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,32 @@ 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
+
102
+ An empty value, `none`, or `null` means unbounded. Limits are per client, so
103
+ processes with multiple event loops can create one configured pool per loop.
104
+
105
+ Call `polyapi.http_client.close()` during synchronous shutdown or await
106
+ `polyapi.http_client.close_async()` on each owning event loop during
107
+ asynchronous shutdown. The shutdown functions wait for active SDK requests
108
+ before closing their client. Clients on standard asyncio loops are closed
109
+ automatically when the loop closes. If a custom event-loop implementation
110
+ reports that its close hook could not be installed, explicitly await
111
+ `close_async()` before closing that loop.
112
+
87
113
  ### 3. Test
88
114
 
89
115
  That's it! Now open up a test file and you can run some code like so:
@@ -43,6 +43,7 @@ tests/test_cli.py
43
43
  tests/test_dependency_pins.py
44
44
  tests/test_deployables.py
45
45
  tests/test_generate.py
46
+ tests/test_generated_poly_boundary.py
46
47
  tests/test_parser.py
47
48
  tests/test_poly_custom.py
48
49
  tests/test_poly_schemas.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "polyapi-python"
7
- version = "0.3.20.dev1"
7
+ version = "0.3.20.dev3"
8
8
  description = "The Python SDK for the integration platform PolyAPI"
9
9
  authors = [{ name = "PolyAPI", email = "support@polyapi.io" }]
10
10
  dependencies = [