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.
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/PKG-INFO +27 -1
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/README.md +27 -1
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/__init__.py +18 -7
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/generate.py +1 -1
- polyapi_python-0.3.20.dev3/polyapi/http_client.py +350 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi_python.egg-info/PKG-INFO +27 -1
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi_python.egg-info/SOURCES.txt +1 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/pyproject.toml +1 -1
- polyapi_python-0.3.20.dev3/tests/test_async_proof.py +1560 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_generate.py +21 -1
- polyapi_python-0.3.20.dev3/tests/test_generated_poly_boundary.py +47 -0
- polyapi_python-0.3.20.dev1/polyapi/http_client.py +0 -83
- polyapi_python-0.3.20.dev1/tests/test_async_proof.py +0 -498
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/LICENSE +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/__main__.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/ai_function.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/api.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/auth.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/cli.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/cli_constants.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/client.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/config.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/constants.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/deployables.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/error_handler.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/exceptions.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/execute.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/function_cli.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/parser.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/poly_schemas.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/poly_tables.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/prepare.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/py.typed +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/rendered_spec.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/schema.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/server.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/sync.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/typedefs.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/utils.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/variables.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi/webhook.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi_python.egg-info/dependency_links.txt +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi_python.egg-info/requires.txt +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/polyapi_python.egg-info/top_level.txt +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/setup.cfg +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_api.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_auth.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_cli.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_dependency_pins.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_deployables.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_parser.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_poly_custom.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_poly_schemas.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_rendered_spec.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_schema.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_server.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_tabi.py +0 -0
- {polyapi_python-0.3.20.dev1 → polyapi_python-0.3.20.dev3}/tests/test_utils.py +0 -0
- {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.
|
|
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
|
|
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):
|
|
@@ -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.
|
|
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:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "polyapi-python"
|
|
7
|
-
version = "0.3.20.
|
|
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 = [
|