Skip to content

Commit bef256c

Browse files
arorashivam96claude
andcommitted
feat: add operation_context kwarg for User-Agent attribution
Accept an optional operation_context parameter on DataverseClient that appends a parenthesized comment to the outbound User-Agent header. This enables plugin/tool attribution without changing the existing UA product token. The context can be passed as a keyword argument on DataverseClient or via DataverseConfig. Passing both raises ValueError to avoid ambiguity. Example UA with context: DataverseSvcPythonClient:0.1.0b10 (app=dataverse-skills/1.2.1;skill=dv-data;agent=claude-code) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7441b5c commit bef256c

4 files changed

Lines changed: 128 additions & 2 deletions

File tree

src/PowerPlatform/Dataverse/client.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,14 @@ class DataverseClient:
4444
:param config: Optional configuration for language, timeouts, and retries.
4545
If not provided, defaults are loaded from :meth:`~PowerPlatform.Dataverse.core.config.DataverseConfig.from_env`.
4646
:type config: ~PowerPlatform.Dataverse.core.config.DataverseConfig or None
47+
:param operation_context: Optional caller-defined context string appended to the
48+
outbound ``User-Agent`` header as a parenthesized comment. Cannot be used
49+
together with ``config`` -- pass the context via
50+
:class:`~PowerPlatform.Dataverse.core.config.DataverseConfig` instead.
51+
:type operation_context: :class:`str` or None
4752
4853
:raises ValueError: If ``base_url`` is missing or empty after trimming.
54+
:raises ValueError: If both ``config`` and ``operation_context`` are provided.
4955
5056
.. note::
5157
The client lazily initializes its internal OData client on first use, allowing lightweight construction without immediate network calls.
@@ -95,12 +101,24 @@ def __init__(
95101
base_url: str,
96102
credential: TokenCredential,
97103
config: Optional[DataverseConfig] = None,
104+
*,
105+
operation_context: Optional[str] = None,
98106
) -> None:
107+
if config is not None and operation_context is not None:
108+
raise ValueError(
109+
"Cannot specify both 'config' and 'operation_context'. "
110+
"Pass operation_context via DataverseConfig instead."
111+
)
99112
self.auth = _AuthManager(credential)
100113
self._base_url = (base_url or "").rstrip("/")
101114
if not self._base_url:
102115
raise ValueError("base_url is required.")
103-
self._config = config or DataverseConfig.from_env()
116+
if config is not None:
117+
self._config = config
118+
elif operation_context is not None:
119+
self._config = DataverseConfig(operation_context=operation_context)
120+
else:
121+
self._config = DataverseConfig.from_env()
104122
self._odata: Optional[_ODataClient] = None
105123
self._session: Optional[requests.Session] = None
106124
self._closed: bool = False

src/PowerPlatform/Dataverse/core/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ class DataverseConfig:
3535
When provided, all HTTP requests and responses are logged to timestamped
3636
``.log`` files with automatic redaction of sensitive headers.
3737
:type log_config: ~PowerPlatform.Dataverse.core.log_config.LogConfig or None
38+
:param operation_context: Optional caller-defined context string appended to the
39+
outbound ``User-Agent`` header as a parenthesized comment. Intended for
40+
plugin/tool attribution (e.g. ``"app=dataverse-skills/1.2.1;skill=dv-data;agent=claude-code"``).
41+
:type operation_context: :class:`str` or None
3842
"""
3943

4044
language_code: int = 1033
@@ -46,6 +50,8 @@ class DataverseConfig:
4650

4751
log_config: Optional["LogConfig"] = None
4852

53+
operation_context: Optional[str] = None
54+
4955
@classmethod
5056
def from_env(cls) -> "DataverseConfig":
5157
"""
@@ -61,4 +67,5 @@ def from_env(cls) -> "DataverseConfig":
6167
http_backoff=None,
6268
http_timeout=None,
6369
log_config=None,
70+
operation_context=None,
6471
)

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ def __init__(
206206
session=session,
207207
logger=self._http_logger,
208208
)
209+
self._operation_context = self.config.operation_context
209210
self._logical_to_entityset_cache: dict[str, str] = {}
210211
# Cache: normalized table_schema_name (lowercase) -> primary id attribute (e.g. accountid)
211212
self._logical_primaryid_cache: dict[str, str] = {}
@@ -241,13 +242,16 @@ def _headers(self) -> Dict[str, str]:
241242
"""Build standard OData headers with bearer auth."""
242243
scope = f"{self.base_url}/.default"
243244
token = self.auth._acquire_token(scope).access_token
245+
ua = _USER_AGENT
246+
if self._operation_context:
247+
ua = f"{_USER_AGENT} ({self._operation_context})"
244248
return {
245249
"Authorization": f"Bearer {token}",
246250
"Accept": "application/json",
247251
"Content-Type": "application/json",
248252
"OData-MaxVersion": "4.0",
249253
"OData-Version": "4.0",
250-
"User-Agent": _USER_AGENT,
254+
"User-Agent": ua,
251255
}
252256

253257
def _merge_headers(self, headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Tests for operation_context support on DataverseClient and User-Agent header."""
5+
6+
import unittest
7+
from unittest.mock import MagicMock
8+
9+
from azure.core.credentials import TokenCredential
10+
11+
from PowerPlatform.Dataverse.client import DataverseClient
12+
from PowerPlatform.Dataverse.core.config import DataverseConfig
13+
from PowerPlatform.Dataverse.data._odata import _ODataClient, _USER_AGENT
14+
15+
16+
class TestOperationContextConfig(unittest.TestCase):
17+
"""Tests for operation_context on DataverseConfig."""
18+
19+
def test_default_is_none(self):
20+
config = DataverseConfig.from_env()
21+
self.assertIsNone(config.operation_context)
22+
23+
def test_explicit_value(self):
24+
config = DataverseConfig(operation_context="app=test/1.0;agent=claude-code")
25+
self.assertEqual(config.operation_context, "app=test/1.0;agent=claude-code")
26+
27+
def test_default_constructor_is_none(self):
28+
config = DataverseConfig()
29+
self.assertIsNone(config.operation_context)
30+
31+
32+
class TestOperationContextClient(unittest.TestCase):
33+
"""Tests for operation_context kwarg on DataverseClient."""
34+
35+
def setUp(self):
36+
self.mock_credential = MagicMock(spec=TokenCredential)
37+
self.base_url = "https://example.crm.dynamics.com"
38+
39+
def test_kwarg_sets_config(self):
40+
client = DataverseClient(
41+
self.base_url, self.mock_credential,
42+
operation_context="app=test/1.0;skill=dv-data;agent=claude-code",
43+
)
44+
self.assertEqual(client._config.operation_context, "app=test/1.0;skill=dv-data;agent=claude-code")
45+
46+
def test_no_kwarg_leaves_config_default(self):
47+
client = DataverseClient(self.base_url, self.mock_credential)
48+
self.assertIsNone(client._config.operation_context)
49+
50+
def test_config_and_kwarg_raises(self):
51+
config = DataverseConfig(operation_context="app=test/1.0")
52+
with self.assertRaises(ValueError):
53+
DataverseClient(
54+
self.base_url, self.mock_credential,
55+
config=config,
56+
operation_context="app=other/2.0",
57+
)
58+
59+
def test_config_alone_works(self):
60+
config = DataverseConfig(operation_context="app=test/1.0;agent=copilot")
61+
client = DataverseClient(self.base_url, self.mock_credential, config=config)
62+
self.assertEqual(client._config.operation_context, "app=test/1.0;agent=copilot")
63+
64+
65+
class TestOperationContextUserAgent(unittest.TestCase):
66+
"""Tests for User-Agent header with operation_context."""
67+
68+
def setUp(self):
69+
self.dummy_auth = MagicMock()
70+
token_result = MagicMock()
71+
token_result.access_token = "test-token"
72+
self.dummy_auth._acquire_token.return_value = token_result
73+
self.base_url = "https://org.example.com"
74+
75+
def test_default_user_agent_unchanged(self):
76+
odata = _ODataClient(self.dummy_auth, self.base_url)
77+
headers = odata._headers()
78+
self.assertEqual(headers["User-Agent"], _USER_AGENT)
79+
80+
def test_operation_context_appended(self):
81+
ctx = "app=dataverse-skills/1.2.1;skill=dv-data;agent=claude-code"
82+
config = DataverseConfig(operation_context=ctx)
83+
odata = _ODataClient(self.dummy_auth, self.base_url, config=config)
84+
headers = odata._headers()
85+
self.assertEqual(headers["User-Agent"], f"{_USER_AGENT} ({ctx})")
86+
87+
def test_none_context_no_parentheses(self):
88+
config = DataverseConfig(operation_context=None)
89+
odata = _ODataClient(self.dummy_auth, self.base_url, config=config)
90+
headers = odata._headers()
91+
self.assertNotIn("(", headers["User-Agent"])
92+
93+
def test_empty_string_context_no_parentheses(self):
94+
config = DataverseConfig(operation_context="")
95+
odata = _ODataClient(self.dummy_auth, self.base_url, config=config)
96+
headers = odata._headers()
97+
self.assertNotIn("(", headers["User-Agent"])

0 commit comments

Comments
 (0)