Skip to content

Commit a6d3f56

Browse files
tpellissierclaude
andcommitted
Add context manager support with HTTP connection pooling
Enable `with DataverseClient(...) as client:` pattern for automatic resource cleanup and requests.Session-based connection pooling (TCP/TLS reuse). Adds close() for explicit lifecycle management and closed-state guards on all operations via _scoped_odata(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 78eb5dd commit a6d3f56

File tree

4 files changed

+397
-17
lines changed

4 files changed

+397
-17
lines changed

src/PowerPlatform/Dataverse/client.py

Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from contextlib import contextmanager
88
from typing import Any, Dict, Iterable, Iterator, List, Optional, Union
99

10+
import requests
11+
1012
from azure.core.credentials import TokenCredential
1113

1214
from .core._auth import _AuthManager
@@ -59,31 +61,29 @@ class DataverseClient:
5961
- ``client.tables`` -- table and column metadata management
6062
- ``client.files`` -- file upload operations
6163
64+
The client supports Python's context manager protocol for automatic resource
65+
cleanup and HTTP connection pooling:
66+
6267
Example:
63-
Create a client and perform basic operations::
68+
**Recommended -- context manager** (enables HTTP connection pooling)::
6469
6570
from azure.identity import InteractiveBrowserCredential
6671
from PowerPlatform.Dataverse.client import DataverseClient
6772
6873
credential = InteractiveBrowserCredential()
69-
client = DataverseClient(
70-
"https://org.crm.dynamics.com",
71-
credential
72-
)
7374
74-
# Create a record
75-
record_id = client.records.create("account", {"name": "Contoso Ltd"})
75+
with DataverseClient("https://org.crm.dynamics.com", credential) as client:
76+
record_id = client.records.create("account", {"name": "Contoso Ltd"})
77+
client.records.update("account", record_id, {"telephone1": "555-0100"})
78+
# Session closed, caches cleared automatically
7679
77-
# Update a record
78-
client.records.update("account", record_id, {"telephone1": "555-0100"})
80+
**Manual lifecycle**::
7981
80-
# Query records
81-
for page in client.records.get("account", filter="name eq 'Contoso Ltd'"):
82-
for account in page:
83-
print(account["name"])
84-
85-
# Delete a record
86-
client.records.delete("account", record_id)
82+
client = DataverseClient("https://org.crm.dynamics.com", credential)
83+
try:
84+
record_id = client.records.create("account", {"name": "Contoso Ltd"})
85+
finally:
86+
client.close()
8787
"""
8888

8989
def __init__(
@@ -98,6 +98,8 @@ def __init__(
9898
raise ValueError("base_url is required.")
9999
self._config = config or DataverseConfig.from_env()
100100
self._odata: Optional[_ODataClient] = None
101+
self._session: Optional[requests.Session] = None
102+
self._closed: bool = False
101103

102104
# Operation namespaces
103105
self.records = RecordOperations(self)
@@ -120,16 +122,74 @@ def _get_odata(self) -> _ODataClient:
120122
self.auth,
121123
self._base_url,
122124
self._config,
125+
session=self._session,
123126
)
124127
return self._odata
125128

126129
@contextmanager
127130
def _scoped_odata(self) -> Iterator[_ODataClient]:
128131
"""Yield the low-level client while ensuring a correlation scope is active."""
132+
self._check_closed()
129133
od = self._get_odata()
130134
with od._call_scope():
131135
yield od
132136

137+
# ---------------- Context manager / lifecycle ----------------
138+
139+
def __enter__(self) -> DataverseClient:
140+
"""Enter the context manager.
141+
142+
Creates a :class:`requests.Session` for HTTP connection pooling.
143+
All operations within the ``with`` block reuse this session for
144+
better performance (TCP and TLS reuse).
145+
146+
:return: The client instance.
147+
:rtype: DataverseClient
148+
"""
149+
if self._session is None:
150+
self._session = requests.Session()
151+
return self
152+
153+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
154+
"""Exit the context manager with cleanup.
155+
156+
Calls :meth:`close` to release resources. Exceptions are not
157+
suppressed.
158+
"""
159+
self.close()
160+
161+
def close(self) -> None:
162+
"""Close the client and release resources.
163+
164+
Closes the HTTP session (if any), clears internal caches, and
165+
marks the client as closed. Safe to call multiple times. After
166+
closing, any operation will raise :class:`RuntimeError`.
167+
168+
Called automatically when using the client as a context manager.
169+
170+
Example::
171+
172+
client = DataverseClient(base_url, credential)
173+
try:
174+
client.records.create("account", {"name": "Contoso"})
175+
finally:
176+
client.close()
177+
"""
178+
if self._closed:
179+
return
180+
if self._odata is not None:
181+
self._odata.close()
182+
self._odata = None
183+
if self._session is not None:
184+
self._session.close()
185+
self._session = None
186+
self._closed = True
187+
188+
def _check_closed(self) -> None:
189+
"""Raise :class:`RuntimeError` if the client has been closed."""
190+
if self._closed:
191+
raise RuntimeError("DataverseClient is closed")
192+
133193
# ---------------- Unified CRUD: create/update/delete ----------------
134194
def create(self, table_schema_name: str, records: Union[Dict[str, Any], List[Dict[str, Any]]]) -> List[str]:
135195
"""

src/PowerPlatform/Dataverse/core/_http.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,23 @@ class _HttpClient:
3030
:type backoff: :class:`float` | None
3131
:param timeout: Default request timeout in seconds. If None, uses per-method defaults.
3232
:type timeout: :class:`float` | None
33+
:param session: Optional ``requests.Session`` for HTTP connection pooling.
34+
When provided, all requests use this session (enabling TCP/TLS reuse).
35+
When ``None``, each request uses ``requests.request()`` directly.
36+
:type session: :class:`requests.Session` | None
3337
"""
3438

3539
def __init__(
3640
self,
3741
retries: Optional[int] = None,
3842
backoff: Optional[float] = None,
3943
timeout: Optional[float] = None,
44+
session: Optional[requests.Session] = None,
4045
) -> None:
4146
self.max_attempts = retries if retries is not None else 5
4247
self.base_delay = backoff if backoff is not None else 0.5
4348
self.default_timeout: Optional[float] = timeout
49+
self._session = session
4450

4551
def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response:
4652
"""
@@ -68,12 +74,22 @@ def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response:
6874
kwargs["timeout"] = 120 if m in ("post", "delete") else 10
6975

7076
# Small backoff retry on network errors only
77+
requester = self._session.request if self._session is not None else requests.request
7178
for attempt in range(self.max_attempts):
7279
try:
73-
return requests.request(method, url, **kwargs)
80+
return requester(method, url, **kwargs)
7481
except requests.exceptions.RequestException:
7582
if attempt == self.max_attempts - 1:
7683
raise
7784
delay = self.base_delay * (2**attempt)
7885
time.sleep(delay)
7986
continue
87+
88+
def close(self) -> None:
89+
"""Close the HTTP client and release resources.
90+
91+
If a session was provided, closes it. Safe to call multiple times.
92+
"""
93+
if self._session is not None:
94+
self._session.close()
95+
self._session = None

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ def __init__(
116116
auth,
117117
base_url: str,
118118
config=None,
119+
session=None,
119120
) -> None:
120121
"""Initialize the OData client.
121122
@@ -127,6 +128,8 @@ def __init__(
127128
:type base_url: ``str``
128129
:param config: Optional Dataverse configuration (HTTP retry, backoff, timeout, language code). If omitted ``DataverseConfig.from_env()`` is used.
129130
:type config: ~PowerPlatform.Dataverse.core.config.DataverseConfig | ``None``
131+
:param session: Optional ``requests.Session`` for HTTP connection pooling.
132+
:type session: :class:`requests.Session` | ``None``
130133
:raises ValueError: If ``base_url`` is empty after stripping.
131134
"""
132135
self.auth = auth
@@ -144,6 +147,7 @@ def __init__(
144147
retries=self.config.http_retries,
145148
backoff=self.config.http_backoff,
146149
timeout=self.config.http_timeout,
150+
session=session,
147151
)
148152
# Cache: normalized table_schema_name (lowercase) -> entity set name (plural) resolved from metadata
149153
self._logical_to_entityset_cache: dict[str, str] = {}
@@ -163,6 +167,18 @@ def _call_scope(self):
163167
finally:
164168
_CALL_SCOPE_CORRELATION_ID.reset(token)
165169

170+
def close(self) -> None:
171+
"""Close the OData client and release resources.
172+
173+
Clears all internal caches and closes the underlying HTTP client.
174+
Safe to call multiple times.
175+
"""
176+
self._logical_to_entityset_cache.clear()
177+
self._logical_primaryid_cache.clear()
178+
self._picklist_label_cache.clear()
179+
if self._http is not None:
180+
self._http.close()
181+
166182
def _headers(self) -> Dict[str, str]:
167183
"""Build standard OData headers with bearer auth."""
168184
scope = f"{self.base_url}/.default"

0 commit comments

Comments
 (0)