Skip to content

Commit 2af249b

Browse files
tpellissier-msfttpellissierclaude
authored
Add context manager support with HTTP connection pooling (microsoft#117)
## Summary - Enable `with DataverseClient(...) as client:` for automatic resource cleanup and HTTP connection pooling - Add `close()` method for explicit lifecycle management (idempotent, safe to call multiple times) - Thread `requests.Session` through `DataverseClient` → `_ODataClient` → `_HttpClient` for TCP/TLS reuse - Guard all operations against use-after-close via `_check_closed()` in `_scoped_odata()` — raises `RuntimeError("DataverseClient is closed")` - Clear all internal caches (entity set, primary ID, picklist) on close - Full backward compatibility: client works identically without `with` statement ### Files changed - `src/PowerPlatform/Dataverse/core/_http.py` — session parameter + `close()` - `src/PowerPlatform/Dataverse/data/_odata.py` — session threading + `close()` with cache clearing - `src/PowerPlatform/Dataverse/client.py` — `__enter__`/`__exit__`/`close()`/`_check_closed()` - `tests/unit/test_context_manager.py` — 34 new tests (protocol, session lifecycle, close behavior, closed-state guards, backward compat, exception handling) ## Test plan - [x] 34 new unit tests covering all context manager behavior - [x] Full test suite passes (195/195) - [ ] Manual verification with live Dataverse environment 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: tpellissier <tpellissier@microsoft.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d391509 commit 2af249b

11 files changed

Lines changed: 463 additions & 54 deletions

File tree

.claude/skills/dataverse-sdk-use/SKILL.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,13 @@ credential = AzureCliCredential()
5353
credential = ClientSecretCredential(tenant_id, client_id, client_secret)
5454
credential = CertificateCredential(tenant_id, client_id, cert_path)
5555

56-
# Create client (no trailing slash on URL!)
56+
# Create client with context manager (recommended -- enables HTTP connection pooling)
57+
# No trailing slash on URL!
58+
with DataverseClient("https://yourorg.crm.dynamics.com", credential) as client:
59+
... # all operations here
60+
# Session closed, caches cleared automatically
61+
62+
# Or without context manager:
5763
client = DataverseClient("https://yourorg.crm.dynamics.com", credential)
5864
```
5965

README.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ The SDK provides a simple, pythonic interface for Dataverse operations:
113113
| Concept | Description |
114114
|---------|-------------|
115115
| **DataverseClient** | Main entry point; provides `records`, `query`, `tables`, and `files` namespaces |
116+
| **Context Manager** | Use `with DataverseClient(...) as client:` for automatic cleanup and HTTP connection pooling |
116117
| **Namespaces** | Operations are organized into `client.records` (CRUD & OData queries), `client.query` (query & search), `client.tables` (metadata), and `client.files` (file uploads) |
117118
| **Records** | Dataverse records represented as Python dictionaries with column schema names |
118119
| **Schema names** | Use table schema names (`"account"`, `"new_MyTestTable"`) and column schema names (`"name"`, `"new_MyTestColumn"`). See: [Table definitions in Microsoft Dataverse](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/entity-metadata) |
@@ -131,17 +132,18 @@ from PowerPlatform.Dataverse.client import DataverseClient
131132

132133
# Connect to Dataverse
133134
credential = InteractiveBrowserCredential()
134-
client = DataverseClient("https://yourorg.crm.dynamics.com", credential)
135135

136-
# Create a contact
137-
contact_id = client.records.create("contact", {"firstname": "John", "lastname": "Doe"})
136+
with DataverseClient("https://yourorg.crm.dynamics.com", credential) as client:
137+
# Create a contact
138+
contact_id = client.records.create("contact", {"firstname": "John", "lastname": "Doe"})
138139

139-
# Read the contact back
140-
contact = client.records.get("contact", contact_id, select=["firstname", "lastname"])
141-
print(f"Created: {contact['firstname']} {contact['lastname']}")
140+
# Read the contact back
141+
contact = client.records.get("contact", contact_id, select=["firstname", "lastname"])
142+
print(f"Created: {contact['firstname']} {contact['lastname']}")
142143

143-
# Clean up
144-
client.records.delete("contact", contact_id)
144+
# Clean up
145+
client.records.delete("contact", contact_id)
146+
# Session closed, caches cleared automatically
145147
```
146148

147149
### Basic CRUD operations

examples/advanced/file_upload.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,4 +375,5 @@ def get_dataset_info(file_path: Path):
375375
except Exception as e: # noqa: BLE001
376376
print({"test_file_8mb_deleted": False, "error": str(e)})
377377

378+
client.close()
378379
print("Done.")

examples/advanced/relationships.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,6 @@ def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)):
108108

109109

110110
def main():
111-
# Initialize relationship IDs to None for cleanup safety
112-
rel_id_1 = None
113-
rel_id_2 = None
114-
rel_id_3 = None
115-
116111
print("=" * 80)
117112
print("Dataverse SDK - Relationship Management Example")
118113
print("=" * 80)
@@ -135,8 +130,16 @@ def main():
135130
credential = InteractiveBrowserCredential()
136131

137132
log_call(f"DataverseClient(base_url='{base_url}', credential=...)")
138-
client = DataverseClient(base_url=base_url, credential=credential)
139-
print(f"[OK] Connected to: {base_url}")
133+
with DataverseClient(base_url=base_url, credential=credential) as client:
134+
print(f"[OK] Connected to: {base_url}")
135+
_run_example(client)
136+
137+
138+
def _run_example(client):
139+
# Initialize relationship IDs to None for cleanup safety
140+
rel_id_1 = None
141+
rel_id_2 = None
142+
rel_id_3 = None
140143

141144
# ============================================================================
142145
# 2. CLEANUP PREVIOUS RUN (Idempotency)

examples/advanced/walkthrough.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,12 @@ def main():
8787
credential = InteractiveBrowserCredential()
8888

8989
log_call(f"DataverseClient(base_url='{base_url}', credential=...)")
90-
client = DataverseClient(base_url=base_url, credential=credential)
91-
print(f"[OK] Connected to: {base_url}")
90+
with DataverseClient(base_url=base_url, credential=credential) as client:
91+
print(f"[OK] Connected to: {base_url}")
92+
_run_walkthrough(client)
9293

94+
95+
def _run_walkthrough(client):
9396
# ============================================================================
9497
# 2. TABLE CREATION (METADATA)
9598
# ============================================================================

examples/basic/installation_example.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,12 @@ def show_usage_examples():
205205
# Set up authentication
206206
credential = InteractiveBrowserCredential()
207207
208-
# Create client
209-
client = DataverseClient(
210-
"https://yourorg.crm.dynamics.com",
211-
credential
212-
)
208+
# Recommended: use context manager for connection pooling and automatic cleanup
209+
with DataverseClient("https://yourorg.crm.dynamics.com", credential) as client:
210+
... # all operations here
211+
212+
# Or without context manager:
213+
client = DataverseClient("https://yourorg.crm.dynamics.com", credential)
213214
```
214215
215216
CRUD Operations:
@@ -307,19 +308,18 @@ def interactive_test():
307308
credential = InteractiveBrowserCredential()
308309

309310
print(" Creating client...")
310-
client = DataverseClient(org_url.rstrip("/"), credential)
311-
312-
print(" Testing connection...")
313-
tables = client.tables.list()
314-
print(f" [OK] Connection successful!")
315-
print(f" Found {len(tables)} tables in environment")
316-
317-
custom_tables = client.tables.list(
318-
filter="IsCustomEntity eq true",
319-
select=["LogicalName", "SchemaName"],
320-
)
321-
print(f" Found {len(custom_tables)} custom tables (filter + select)")
322-
print(f" Connected to: {org_url}")
311+
with DataverseClient(org_url.rstrip("/"), credential) as client:
312+
print(" Testing connection...")
313+
tables = client.tables.list()
314+
print(f" [OK] Connection successful!")
315+
print(f" Found {len(tables)} tables in environment")
316+
317+
custom_tables = client.tables.list(
318+
filter="IsCustomEntity eq true",
319+
select=["LogicalName", "SchemaName"],
320+
)
321+
print(f" Found {len(custom_tables)} custom tables (filter + select)")
322+
print(f" Connected to: {org_url}")
323323

324324
print("\n Your SDK is ready for use!")
325325
print(" Check the usage examples above for common patterns")

src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,13 @@ credential = AzureCliCredential()
5353
credential = ClientSecretCredential(tenant_id, client_id, client_secret)
5454
credential = CertificateCredential(tenant_id, client_id, cert_path)
5555

56-
# Create client (no trailing slash on URL!)
56+
# Create client with context manager (recommended -- enables HTTP connection pooling)
57+
# No trailing slash on URL!
58+
with DataverseClient("https://yourorg.crm.dynamics.com", credential) as client:
59+
... # all operations here
60+
# Session closed, caches cleared automatically
61+
62+
# Or without context manager:
5763
client = DataverseClient("https://yourorg.crm.dynamics.com", credential)
5864
```
5965

src/PowerPlatform/Dataverse/client.py

Lines changed: 79 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,77 @@ 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+
:raises RuntimeError: If the client has been closed.
150+
"""
151+
self._check_closed()
152+
if self._session is None:
153+
self._session = requests.Session()
154+
return self
155+
156+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
157+
"""Exit the context manager with cleanup.
158+
159+
Calls :meth:`close` to release resources. Exceptions are not
160+
suppressed.
161+
"""
162+
self.close()
163+
164+
def close(self) -> None:
165+
"""Close the client and release resources.
166+
167+
Closes the HTTP session (if any), clears internal caches, and
168+
marks the client as closed. Safe to call multiple times. After
169+
closing, any operation will raise :class:`RuntimeError`.
170+
171+
Called automatically when using the client as a context manager.
172+
173+
Example::
174+
175+
client = DataverseClient(base_url, credential)
176+
try:
177+
client.records.create("account", {"name": "Contoso"})
178+
finally:
179+
client.close()
180+
"""
181+
if self._closed:
182+
return
183+
if self._odata is not None:
184+
self._odata.close()
185+
self._odata = None
186+
if self._session is not None:
187+
self._session.close()
188+
self._session = None
189+
self._closed = True
190+
191+
def _check_closed(self) -> None:
192+
"""Raise :class:`RuntimeError` if the client has been closed."""
193+
if self._closed:
194+
raise RuntimeError("DataverseClient is closed")
195+
133196
# ---------------- Unified CRUD: create/update/delete ----------------
134197
def create(self, table_schema_name: str, records: Union[Dict[str, Any], List[Dict[str, Any]]]) -> List[str]:
135198
"""

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

0 commit comments

Comments
 (0)