Skip to content

Commit 8039dd0

Browse files
Abel Milashclaude
andcommitted
Merge origin/main: operation_context User-Agent attribution (#178)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 parents 656ed26 + 5eae887 commit 8039dd0

7 files changed

Lines changed: 228 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3333
- All v0 flat methods on `DataverseClient` (`create`, `update`, `delete`, `get`, `list`, `query_sql`, etc.) removed (~570 lines); use the `client.records`, `client.query`, and `client.batch` namespaces (#175)
3434
- `client.query.sql_select()`, `client.query.sql_joins()`, `client.query.sql_join()` removed (#175)
3535

36+
## [0.1.0b10] - 2026-05-12
37+
38+
### Added
39+
- `DataverseClient(context=OperationContext(...))` keyword argument (also settable via `DataverseConfig(operation_context=...)`) that appends a parenthesized comment to the outbound `User-Agent` header so callers can attribute their traffic in Dataverse server logs — e.g. `DataverseSvcPythonClient:0.1.0b10 (app=dataverse-skills/1.2.1)` (#178)
40+
3641
## [0.1.0b9] - 2026-04-28
3742

3843
### Added
@@ -151,7 +156,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
151156
- Comprehensive error handling with specific exception types (`DataverseError`, `AuthenticationError`, etc.) (#22, #24)
152157
- HTTP retry logic with exponential backoff for resilient operations (#72)
153158

154-
[Unreleased]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b9...HEAD
159+
[Unreleased]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b10...HEAD
160+
[0.1.0b10]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b9...v0.1.0b10
155161
[0.1.0b9]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b8...v0.1.0b9
156162
[0.1.0b8]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b7...v0.1.0b8
157163
[0.1.0b7]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b6...v0.1.0b7

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "PowerPlatform-Dataverse-Client"
7-
version = "0.1.0b10"
7+
version = "0.1.0b11"
88
description = "Python SDK for Microsoft Dataverse"
99
readme = {file = "README.md", content-type = "text/markdown"}
1010
authors = [{name = "Microsoft Corporation"}]

src/PowerPlatform/Dataverse/client.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from azure.core.credentials import TokenCredential
1212

1313
from .core._auth import _AuthManager
14-
from .core.config import DataverseConfig
14+
from .core.config import DataverseConfig, OperationContext
1515
from .data._odata import _ODataClient
1616
from .operations.dataframe import DataFrameOperations
1717
from .operations.records import RecordOperations
@@ -43,8 +43,14 @@ class DataverseClient:
4343
:param config: Optional configuration for language, timeouts, and retries.
4444
If not provided, defaults are loaded from :meth:`~PowerPlatform.Dataverse.core.config.DataverseConfig.from_env`.
4545
:type config: ~PowerPlatform.Dataverse.core.config.DataverseConfig or None
46+
:param context: Optional caller-defined context object appended to the
47+
outbound ``User-Agent`` header for plugin/tool attribution. Cannot be used
48+
together with ``config`` -- pass the context via
49+
:class:`~PowerPlatform.Dataverse.core.config.DataverseConfig` instead.
50+
:type context: ~PowerPlatform.Dataverse.core.config.OperationContext or None
4651
4752
:raises ValueError: If ``base_url`` is missing or empty after trimming.
53+
:raises ValueError: If both ``config`` and ``context`` are provided.
4854
4955
.. note::
5056
The client lazily initializes its internal OData client on first use, allowing lightweight construction without immediate network calls.
@@ -94,12 +100,23 @@ def __init__(
94100
base_url: str,
95101
credential: TokenCredential,
96102
config: Optional[DataverseConfig] = None,
103+
*,
104+
context: Optional[OperationContext] = None,
97105
) -> None:
106+
if config is not None and context is not None:
107+
raise ValueError(
108+
"Cannot specify both 'config' and 'context'. " "Pass operation_context via DataverseConfig instead."
109+
)
98110
self.auth = _AuthManager(credential)
99111
self._base_url = (base_url or "").rstrip("/")
100112
if not self._base_url:
101113
raise ValueError("base_url is required.")
102-
self._config = config or DataverseConfig.from_env()
114+
if config is not None:
115+
self._config = config
116+
elif context is not None:
117+
self._config = DataverseConfig(operation_context=context)
118+
else:
119+
self._config = DataverseConfig.from_env()
103120
self._odata: Optional[_ODataClient] = None
104121
self._session: Optional[requests.Session] = None
105122
self._closed: bool = False

src/PowerPlatform/Dataverse/core/config.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,50 @@
1111

1212
from __future__ import annotations
1313

14+
import re
1415
from dataclasses import dataclass
1516
from typing import TYPE_CHECKING, Optional
1617

1718
if TYPE_CHECKING:
1819
from .log_config import LogConfig
1920

21+
# key=value pairs separated by semicolons.
22+
# Keys: alphanumeric, hyphens, underscores.
23+
# Values: alphanumeric, hyphens, underscores, dots, slashes.
24+
_CONTEXT_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+(;[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+)*$")
25+
26+
27+
@dataclass(frozen=True)
28+
class OperationContext:
29+
"""Caller-defined context appended to outbound ``User-Agent`` headers.
30+
31+
The context string is validated to be semicolon-separated ``key=value`` pairs
32+
(e.g. ``"app=myapp/1.0;agent=claude-code"``). Free-form text, email
33+
addresses, and other potentially sensitive strings are rejected.
34+
35+
:param user_agent_context: Attribution string in ``key=value;key=value`` format.
36+
:type user_agent_context: :class:`str`
37+
38+
:raises ValueError: If the string is empty, contains control characters, or
39+
does not match the required ``key=value`` format.
40+
"""
41+
42+
user_agent_context: str
43+
44+
def __post_init__(self) -> None:
45+
val = self.user_agent_context
46+
if not val:
47+
raise ValueError("operation_context must not be empty.")
48+
if any(c in val for c in "\r\n\x00"):
49+
raise ValueError("operation_context must not contain CR, LF, or NUL characters.")
50+
if not _CONTEXT_PATTERN.match(val):
51+
raise ValueError(
52+
"operation_context must be semicolon-separated key=value pairs "
53+
"(e.g. 'app=myapp/1.0;agent=claude-code'). "
54+
"Keys and values may contain alphanumerics, hyphens, underscores, "
55+
"dots, and slashes."
56+
)
57+
2058

2159
@dataclass(frozen=True)
2260
class DataverseConfig:
@@ -35,6 +73,10 @@ class DataverseConfig:
3573
When provided, all HTTP requests and responses are logged to timestamped
3674
``.log`` files with automatic redaction of sensitive headers.
3775
:type log_config: ~PowerPlatform.Dataverse.core.log_config.LogConfig or None
76+
:param operation_context: Optional caller-defined context object appended to the
77+
outbound ``User-Agent`` header as a parenthesized comment. Intended for
78+
plugin/tool attribution.
79+
:type operation_context: ~PowerPlatform.Dataverse.core.config.OperationContext or None
3880
"""
3981

4082
language_code: int = 1033
@@ -46,6 +88,8 @@ class DataverseConfig:
4688

4789
log_config: Optional["LogConfig"] = None
4890

91+
operation_context: Optional[OperationContext] = None
92+
4993
@classmethod
5094
def from_env(cls) -> "DataverseConfig":
5195
"""
@@ -61,4 +105,5 @@ def from_env(cls) -> "DataverseConfig":
61105
http_backoff=None,
62106
http_timeout=None,
63107
log_config=None,
108+
operation_context=None,
64109
)

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ def __init__(
8181
session=session,
8282
logger=self._http_logger,
8383
)
84+
ctx_obj = self.config.operation_context
85+
self._operation_context = ctx_obj.user_agent_context if ctx_obj else None
8486

8587
def close(self) -> None:
8688
"""Close the OData client and release resources.
@@ -96,13 +98,16 @@ def _headers(self) -> Dict[str, str]:
9698
"""Build standard OData headers with bearer auth."""
9799
scope = f"{self.base_url}/.default"
98100
token = self.auth._acquire_token(scope).access_token
101+
ua = _USER_AGENT
102+
if self._operation_context:
103+
ua = f"{_USER_AGENT} ({self._operation_context})"
99104
return {
100105
"Authorization": f"Bearer {token}",
101106
"Accept": "application/json",
102107
"Content-Type": "application/json",
103108
"OData-MaxVersion": "4.0",
104109
"OData-Version": "4.0",
105-
"User-Agent": _USER_AGENT,
110+
"User-Agent": ua,
106111
}
107112

108113
def _merge_headers(self, headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:

tests/unit/data/test_enum_optionset_payload.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def __init__(self, language_code=1033):
2525
self.http_backoff = 0
2626
self.http_timeout = 5
2727
self.log_config = None
28+
self.operation_context = None # None or OperationContext object
2829

2930

3031
def _make_client(lang=1033):
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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, OperationContext
13+
from PowerPlatform.Dataverse.data._odata import _ODataClient, _USER_AGENT
14+
15+
16+
class TestOperationContextValidation(unittest.TestCase):
17+
"""Tests for OperationContext format validation and PII rejection."""
18+
19+
def test_valid_single_pair(self):
20+
ctx = OperationContext(user_agent_context="app=test/1.0")
21+
self.assertEqual(ctx.user_agent_context, "app=test/1.0")
22+
23+
def test_valid_multiple_pairs(self):
24+
ctx = OperationContext(user_agent_context="app=test/1.0;skill=dv-data;agent=claude-code")
25+
self.assertEqual(ctx.user_agent_context, "app=test/1.0;skill=dv-data;agent=claude-code")
26+
27+
def test_valid_with_dots_slashes_hyphens(self):
28+
ctx = OperationContext(user_agent_context="app=dataverse-skills/1.2.1")
29+
self.assertEqual(ctx.user_agent_context, "app=dataverse-skills/1.2.1")
30+
31+
def test_reject_empty(self):
32+
with self.assertRaises(ValueError):
33+
OperationContext(user_agent_context="")
34+
35+
def test_reject_email(self):
36+
with self.assertRaises(ValueError):
37+
OperationContext(user_agent_context="myname@email.com")
38+
39+
def test_reject_freeform_text(self):
40+
with self.assertRaises(ValueError):
41+
OperationContext(user_agent_context="my bank password is 1234")
42+
43+
def test_reject_control_chars(self):
44+
for bad in ["has\rnewline", "has\nnewline", "has\x00null"]:
45+
with self.assertRaises(ValueError):
46+
OperationContext(user_agent_context=bad)
47+
48+
def test_reject_spaces(self):
49+
with self.assertRaises(ValueError):
50+
OperationContext(user_agent_context="app=my app")
51+
52+
def test_reject_no_equals(self):
53+
with self.assertRaises(ValueError):
54+
OperationContext(user_agent_context="justaplainstring")
55+
56+
57+
class TestOperationContextConfig(unittest.TestCase):
58+
"""Tests for operation_context on DataverseConfig."""
59+
60+
def test_default_is_none(self):
61+
config = DataverseConfig.from_env()
62+
self.assertIsNone(config.operation_context)
63+
64+
def test_explicit_value(self):
65+
ctx = OperationContext(user_agent_context="app=test/1.0;agent=claude-code")
66+
config = DataverseConfig(operation_context=ctx)
67+
self.assertEqual(config.operation_context.user_agent_context, "app=test/1.0;agent=claude-code")
68+
69+
def test_default_constructor_is_none(self):
70+
config = DataverseConfig()
71+
self.assertIsNone(config.operation_context)
72+
73+
74+
class TestOperationContextClient(unittest.TestCase):
75+
"""Tests for context kwarg on DataverseClient."""
76+
77+
def setUp(self):
78+
self.mock_credential = MagicMock(spec=TokenCredential)
79+
self.base_url = "https://example.crm.dynamics.com"
80+
81+
def test_kwarg_sets_config(self):
82+
ctx = OperationContext(user_agent_context="app=test/1.0;skill=dv-data;agent=claude-code")
83+
client = DataverseClient(
84+
self.base_url,
85+
self.mock_credential,
86+
context=ctx,
87+
)
88+
self.assertEqual(
89+
client._config.operation_context.user_agent_context,
90+
"app=test/1.0;skill=dv-data;agent=claude-code",
91+
)
92+
93+
def test_no_kwarg_leaves_config_default(self):
94+
client = DataverseClient(self.base_url, self.mock_credential)
95+
self.assertIsNone(client._config.operation_context)
96+
97+
def test_config_and_context_raises(self):
98+
ctx = OperationContext(user_agent_context="app=test/1.0")
99+
config = DataverseConfig(operation_context=ctx)
100+
with self.assertRaises(ValueError):
101+
DataverseClient(
102+
self.base_url,
103+
self.mock_credential,
104+
config=config,
105+
context=OperationContext(user_agent_context="app=other/2.0"),
106+
)
107+
108+
def test_config_alone_works(self):
109+
ctx = OperationContext(user_agent_context="app=test/1.0;agent=copilot")
110+
config = DataverseConfig(operation_context=ctx)
111+
client = DataverseClient(self.base_url, self.mock_credential, config=config)
112+
self.assertEqual(
113+
client._config.operation_context.user_agent_context,
114+
"app=test/1.0;agent=copilot",
115+
)
116+
117+
118+
class TestOperationContextUserAgent(unittest.TestCase):
119+
"""Tests for User-Agent header with operation_context."""
120+
121+
def setUp(self):
122+
self.dummy_auth = MagicMock()
123+
token_result = MagicMock()
124+
token_result.access_token = "test-token"
125+
self.dummy_auth._acquire_token.return_value = token_result
126+
self.base_url = "https://org.example.com"
127+
128+
def test_default_user_agent_unchanged(self):
129+
odata = _ODataClient(self.dummy_auth, self.base_url)
130+
headers = odata._headers()
131+
self.assertEqual(headers["User-Agent"], _USER_AGENT)
132+
133+
def test_operation_context_appended(self):
134+
ctx_str = "app=dataverse-skills/1.2.1;skill=dv-data;agent=claude-code"
135+
ctx = OperationContext(user_agent_context=ctx_str)
136+
config = DataverseConfig(operation_context=ctx)
137+
odata = _ODataClient(self.dummy_auth, self.base_url, config=config)
138+
headers = odata._headers()
139+
self.assertEqual(headers["User-Agent"], f"{_USER_AGENT} ({ctx_str})")
140+
141+
def test_none_context_no_parentheses(self):
142+
config = DataverseConfig(operation_context=None)
143+
odata = _ODataClient(self.dummy_auth, self.base_url, config=config)
144+
headers = odata._headers()
145+
self.assertNotIn("(", headers["User-Agent"])
146+
147+
def test_empty_string_rejected_at_creation(self):
148+
with self.assertRaises(ValueError):
149+
OperationContext(user_agent_context="")

0 commit comments

Comments
 (0)