From bef256c25c9484324b7c2eafd2d90b5803d9a311 Mon Sep 17 00:00:00 2001 From: shivamarora <122318437+arorashivam96@users.noreply.github.com> Date: Fri, 8 May 2026 09:22:25 -0700 Subject: [PATCH 1/7] 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) --- src/PowerPlatform/Dataverse/client.py | 20 ++++- src/PowerPlatform/Dataverse/core/config.py | 7 ++ src/PowerPlatform/Dataverse/data/_odata.py | 6 +- tests/unit/test_operation_context.py | 97 ++++++++++++++++++++++ 4 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_operation_context.py diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index ea9dd6b8..f393eac1 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -44,8 +44,14 @@ class DataverseClient: :param config: Optional configuration for language, timeouts, and retries. If not provided, defaults are loaded from :meth:`~PowerPlatform.Dataverse.core.config.DataverseConfig.from_env`. :type config: ~PowerPlatform.Dataverse.core.config.DataverseConfig or None + :param operation_context: Optional caller-defined context string appended to the + outbound ``User-Agent`` header as a parenthesized comment. Cannot be used + together with ``config`` -- pass the context via + :class:`~PowerPlatform.Dataverse.core.config.DataverseConfig` instead. + :type operation_context: :class:`str` or None :raises ValueError: If ``base_url`` is missing or empty after trimming. + :raises ValueError: If both ``config`` and ``operation_context`` are provided. .. note:: The client lazily initializes its internal OData client on first use, allowing lightweight construction without immediate network calls. @@ -95,12 +101,24 @@ def __init__( base_url: str, credential: TokenCredential, config: Optional[DataverseConfig] = None, + *, + operation_context: Optional[str] = None, ) -> None: + if config is not None and operation_context is not None: + raise ValueError( + "Cannot specify both 'config' and 'operation_context'. " + "Pass operation_context via DataverseConfig instead." + ) self.auth = _AuthManager(credential) self._base_url = (base_url or "").rstrip("/") if not self._base_url: raise ValueError("base_url is required.") - self._config = config or DataverseConfig.from_env() + if config is not None: + self._config = config + elif operation_context is not None: + self._config = DataverseConfig(operation_context=operation_context) + else: + self._config = DataverseConfig.from_env() self._odata: Optional[_ODataClient] = None self._session: Optional[requests.Session] = None self._closed: bool = False diff --git a/src/PowerPlatform/Dataverse/core/config.py b/src/PowerPlatform/Dataverse/core/config.py index 9c7c41f5..df717b1c 100644 --- a/src/PowerPlatform/Dataverse/core/config.py +++ b/src/PowerPlatform/Dataverse/core/config.py @@ -35,6 +35,10 @@ class DataverseConfig: When provided, all HTTP requests and responses are logged to timestamped ``.log`` files with automatic redaction of sensitive headers. :type log_config: ~PowerPlatform.Dataverse.core.log_config.LogConfig or None + :param operation_context: Optional caller-defined context string appended to the + outbound ``User-Agent`` header as a parenthesized comment. Intended for + plugin/tool attribution (e.g. ``"app=dataverse-skills/1.2.1;skill=dv-data;agent=claude-code"``). + :type operation_context: :class:`str` or None """ language_code: int = 1033 @@ -46,6 +50,8 @@ class DataverseConfig: log_config: Optional["LogConfig"] = None + operation_context: Optional[str] = None + @classmethod def from_env(cls) -> "DataverseConfig": """ @@ -61,4 +67,5 @@ def from_env(cls) -> "DataverseConfig": http_backoff=None, http_timeout=None, log_config=None, + operation_context=None, ) diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index 4848e840..b5167424 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -206,6 +206,7 @@ def __init__( session=session, logger=self._http_logger, ) + self._operation_context = self.config.operation_context self._logical_to_entityset_cache: dict[str, str] = {} # Cache: normalized table_schema_name (lowercase) -> primary id attribute (e.g. accountid) self._logical_primaryid_cache: dict[str, str] = {} @@ -241,13 +242,16 @@ def _headers(self) -> Dict[str, str]: """Build standard OData headers with bearer auth.""" scope = f"{self.base_url}/.default" token = self.auth._acquire_token(scope).access_token + ua = _USER_AGENT + if self._operation_context: + ua = f"{_USER_AGENT} ({self._operation_context})" return { "Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json", "OData-MaxVersion": "4.0", "OData-Version": "4.0", - "User-Agent": _USER_AGENT, + "User-Agent": ua, } def _merge_headers(self, headers: Optional[Dict[str, str]] = None) -> Dict[str, str]: diff --git a/tests/unit/test_operation_context.py b/tests/unit/test_operation_context.py new file mode 100644 index 00000000..43651b56 --- /dev/null +++ b/tests/unit/test_operation_context.py @@ -0,0 +1,97 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Tests for operation_context support on DataverseClient and User-Agent header.""" + +import unittest +from unittest.mock import MagicMock + +from azure.core.credentials import TokenCredential + +from PowerPlatform.Dataverse.client import DataverseClient +from PowerPlatform.Dataverse.core.config import DataverseConfig +from PowerPlatform.Dataverse.data._odata import _ODataClient, _USER_AGENT + + +class TestOperationContextConfig(unittest.TestCase): + """Tests for operation_context on DataverseConfig.""" + + def test_default_is_none(self): + config = DataverseConfig.from_env() + self.assertIsNone(config.operation_context) + + def test_explicit_value(self): + config = DataverseConfig(operation_context="app=test/1.0;agent=claude-code") + self.assertEqual(config.operation_context, "app=test/1.0;agent=claude-code") + + def test_default_constructor_is_none(self): + config = DataverseConfig() + self.assertIsNone(config.operation_context) + + +class TestOperationContextClient(unittest.TestCase): + """Tests for operation_context kwarg on DataverseClient.""" + + def setUp(self): + self.mock_credential = MagicMock(spec=TokenCredential) + self.base_url = "https://example.crm.dynamics.com" + + def test_kwarg_sets_config(self): + client = DataverseClient( + self.base_url, self.mock_credential, + operation_context="app=test/1.0;skill=dv-data;agent=claude-code", + ) + self.assertEqual(client._config.operation_context, "app=test/1.0;skill=dv-data;agent=claude-code") + + def test_no_kwarg_leaves_config_default(self): + client = DataverseClient(self.base_url, self.mock_credential) + self.assertIsNone(client._config.operation_context) + + def test_config_and_kwarg_raises(self): + config = DataverseConfig(operation_context="app=test/1.0") + with self.assertRaises(ValueError): + DataverseClient( + self.base_url, self.mock_credential, + config=config, + operation_context="app=other/2.0", + ) + + def test_config_alone_works(self): + config = DataverseConfig(operation_context="app=test/1.0;agent=copilot") + client = DataverseClient(self.base_url, self.mock_credential, config=config) + self.assertEqual(client._config.operation_context, "app=test/1.0;agent=copilot") + + +class TestOperationContextUserAgent(unittest.TestCase): + """Tests for User-Agent header with operation_context.""" + + def setUp(self): + self.dummy_auth = MagicMock() + token_result = MagicMock() + token_result.access_token = "test-token" + self.dummy_auth._acquire_token.return_value = token_result + self.base_url = "https://org.example.com" + + def test_default_user_agent_unchanged(self): + odata = _ODataClient(self.dummy_auth, self.base_url) + headers = odata._headers() + self.assertEqual(headers["User-Agent"], _USER_AGENT) + + def test_operation_context_appended(self): + ctx = "app=dataverse-skills/1.2.1;skill=dv-data;agent=claude-code" + config = DataverseConfig(operation_context=ctx) + odata = _ODataClient(self.dummy_auth, self.base_url, config=config) + headers = odata._headers() + self.assertEqual(headers["User-Agent"], f"{_USER_AGENT} ({ctx})") + + def test_none_context_no_parentheses(self): + config = DataverseConfig(operation_context=None) + odata = _ODataClient(self.dummy_auth, self.base_url, config=config) + headers = odata._headers() + self.assertNotIn("(", headers["User-Agent"]) + + def test_empty_string_context_no_parentheses(self): + config = DataverseConfig(operation_context="") + odata = _ODataClient(self.dummy_auth, self.base_url, config=config) + headers = odata._headers() + self.assertNotIn("(", headers["User-Agent"]) From 493eadc353e4e62194d01c49de4ffdb140863d43 Mon Sep 17 00:00:00 2001 From: shivamarora <122318437+arorashivam96@users.noreply.github.com> Date: Fri, 8 May 2026 14:46:31 -0700 Subject: [PATCH 2/7] style: fix black formatting in test_operation_context.py Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/unit/test_operation_context.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_operation_context.py b/tests/unit/test_operation_context.py index 43651b56..842fe281 100644 --- a/tests/unit/test_operation_context.py +++ b/tests/unit/test_operation_context.py @@ -38,7 +38,8 @@ def setUp(self): def test_kwarg_sets_config(self): client = DataverseClient( - self.base_url, self.mock_credential, + self.base_url, + self.mock_credential, operation_context="app=test/1.0;skill=dv-data;agent=claude-code", ) self.assertEqual(client._config.operation_context, "app=test/1.0;skill=dv-data;agent=claude-code") @@ -51,7 +52,8 @@ def test_config_and_kwarg_raises(self): config = DataverseConfig(operation_context="app=test/1.0") with self.assertRaises(ValueError): DataverseClient( - self.base_url, self.mock_credential, + self.base_url, + self.mock_credential, config=config, operation_context="app=other/2.0", ) From d4fbb183aa2dd7f709b51d76e44ae1862d3b9ce8 Mon Sep 17 00:00:00 2001 From: shivamarora <122318437+arorashivam96@users.noreply.github.com> Date: Fri, 8 May 2026 15:37:02 -0700 Subject: [PATCH 3/7] fix: add operation_context to DummyConfig in test_enum_optionset_payload The hand-rolled DummyConfig stub was missing the new operation_context attribute, causing _ODataClient.__init__ to fail with AttributeError. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/unit/data/test_enum_optionset_payload.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/data/test_enum_optionset_payload.py b/tests/unit/data/test_enum_optionset_payload.py index a6efdf5a..adae4ba0 100644 --- a/tests/unit/data/test_enum_optionset_payload.py +++ b/tests/unit/data/test_enum_optionset_payload.py @@ -25,6 +25,7 @@ def __init__(self, language_code=1033): self.http_backoff = 0 self.http_timeout = 5 self.log_config = None + self.operation_context = None def _make_client(lang=1033): From 07e3eb21823379dfac1b8c8f294fd922a643ade5 Mon Sep 17 00:00:00 2001 From: shivamarora <122318437+arorashivam96@users.noreply.github.com> Date: Fri, 8 May 2026 17:45:41 -0700 Subject: [PATCH 4/7] fix: reject CR/LF/NUL in operation_context to prevent header injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate operation_context at ODataClient init time — raise ValueError if the string contains \r, \n, or \x00. Prevents invalid HTTP headers and header injection via the User-Agent comment. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PowerPlatform/Dataverse/data/_odata.py | 7 ++++++- tests/unit/test_operation_context.py | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index b5167424..f000f678 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -206,7 +206,12 @@ def __init__( session=session, logger=self._http_logger, ) - self._operation_context = self.config.operation_context + ctx = self.config.operation_context + if ctx and any(c in ctx for c in "\r\n\x00"): + raise ValueError( + "operation_context must not contain CR, LF, or NUL characters." + ) + self._operation_context = ctx self._logical_to_entityset_cache: dict[str, str] = {} # Cache: normalized table_schema_name (lowercase) -> primary id attribute (e.g. accountid) self._logical_primaryid_cache: dict[str, str] = {} diff --git a/tests/unit/test_operation_context.py b/tests/unit/test_operation_context.py index 842fe281..0a203fd8 100644 --- a/tests/unit/test_operation_context.py +++ b/tests/unit/test_operation_context.py @@ -97,3 +97,9 @@ def test_empty_string_context_no_parentheses(self): odata = _ODataClient(self.dummy_auth, self.base_url, config=config) headers = odata._headers() self.assertNotIn("(", headers["User-Agent"]) + + def test_control_chars_rejected(self): + for bad in ["has\rnewline", "has\nnewline", "has\x00null"]: + config = DataverseConfig(operation_context=bad) + with self.assertRaises(ValueError): + _ODataClient(self.dummy_auth, self.base_url, config=config) From dbdeb1255b0afec500755a21abbdeecd278cf647 Mon Sep 17 00:00:00 2001 From: shivamarora <122318437+arorashivam96@users.noreply.github.com> Date: Fri, 8 May 2026 17:49:39 -0700 Subject: [PATCH 5/7] style: fix black formatting in _odata.py Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PowerPlatform/Dataverse/data/_odata.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index f000f678..3a1440bc 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -208,9 +208,7 @@ def __init__( ) ctx = self.config.operation_context if ctx and any(c in ctx for c in "\r\n\x00"): - raise ValueError( - "operation_context must not contain CR, LF, or NUL characters." - ) + raise ValueError("operation_context must not contain CR, LF, or NUL characters.") self._operation_context = ctx self._logical_to_entityset_cache: dict[str, str] = {} # Cache: normalized table_schema_name (lowercase) -> primary id attribute (e.g. accountid) From 0a7499d062f9696bb909c9083ac0e54dc24a985e Mon Sep 17 00:00:00 2001 From: shivamarora <122318437+arorashivam96@users.noreply.github.com> Date: Mon, 11 May 2026 18:11:12 -0700 Subject: [PATCH 6/7] refactor: make operation_context a structured object with PII validation Address PR comments: - Replace raw string with OperationContext dataclass (key=value format) - Add regex validation to reject PII (emails, free-form text, passwords) - Rename client kwarg from operation_context to context - Validation happens at OperationContext creation time (fail-fast) - 20 unit tests covering valid formats, PII rejection, and User-Agent header --- src/PowerPlatform/Dataverse/client.py | 21 ++--- src/PowerPlatform/Dataverse/core/config.py | 46 +++++++++- src/PowerPlatform/Dataverse/data/_odata.py | 6 +- .../unit/data/test_enum_optionset_payload.py | 2 +- tests/unit/test_operation_context.py | 92 ++++++++++++++----- 5 files changed, 123 insertions(+), 44 deletions(-) diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index f393eac1..29be479d 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -12,7 +12,7 @@ from azure.core.credentials import TokenCredential from .core._auth import _AuthManager -from .core.config import DataverseConfig +from .core.config import DataverseConfig, OperationContext from .data._odata import _ODataClient from .operations.dataframe import DataFrameOperations from .operations.records import RecordOperations @@ -44,14 +44,14 @@ class DataverseClient: :param config: Optional configuration for language, timeouts, and retries. If not provided, defaults are loaded from :meth:`~PowerPlatform.Dataverse.core.config.DataverseConfig.from_env`. :type config: ~PowerPlatform.Dataverse.core.config.DataverseConfig or None - :param operation_context: Optional caller-defined context string appended to the - outbound ``User-Agent`` header as a parenthesized comment. Cannot be used + :param context: Optional caller-defined context object appended to the + outbound ``User-Agent`` header for plugin/tool attribution. Cannot be used together with ``config`` -- pass the context via :class:`~PowerPlatform.Dataverse.core.config.DataverseConfig` instead. - :type operation_context: :class:`str` or None + :type context: ~PowerPlatform.Dataverse.core.config.OperationContext or None :raises ValueError: If ``base_url`` is missing or empty after trimming. - :raises ValueError: If both ``config`` and ``operation_context`` are provided. + :raises ValueError: If both ``config`` and ``context`` are provided. .. note:: The client lazily initializes its internal OData client on first use, allowing lightweight construction without immediate network calls. @@ -102,12 +102,11 @@ def __init__( credential: TokenCredential, config: Optional[DataverseConfig] = None, *, - operation_context: Optional[str] = None, + context: Optional[OperationContext] = None, ) -> None: - if config is not None and operation_context is not None: + if config is not None and context is not None: raise ValueError( - "Cannot specify both 'config' and 'operation_context'. " - "Pass operation_context via DataverseConfig instead." + "Cannot specify both 'config' and 'context'. " "Pass operation_context via DataverseConfig instead." ) self.auth = _AuthManager(credential) self._base_url = (base_url or "").rstrip("/") @@ -115,8 +114,8 @@ def __init__( raise ValueError("base_url is required.") if config is not None: self._config = config - elif operation_context is not None: - self._config = DataverseConfig(operation_context=operation_context) + elif context is not None: + self._config = DataverseConfig(operation_context=context) else: self._config = DataverseConfig.from_env() self._odata: Optional[_ODataClient] = None diff --git a/src/PowerPlatform/Dataverse/core/config.py b/src/PowerPlatform/Dataverse/core/config.py index df717b1c..db77af08 100644 --- a/src/PowerPlatform/Dataverse/core/config.py +++ b/src/PowerPlatform/Dataverse/core/config.py @@ -11,12 +11,50 @@ from __future__ import annotations +import re from dataclasses import dataclass from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from .log_config import LogConfig +# key=value pairs separated by semicolons. +# Keys: alphanumeric, hyphens, underscores. +# Values: alphanumeric, hyphens, underscores, dots, slashes. +_CONTEXT_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+(;[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+)*$") + + +@dataclass(frozen=True) +class OperationContext: + """Caller-defined context appended to outbound ``User-Agent`` headers. + + The context string is validated to be semicolon-separated ``key=value`` pairs + (e.g. ``"app=myapp/1.0;agent=claude-code"``). Free-form text, email + addresses, and other potentially sensitive strings are rejected. + + :param operation_context: Attribution string in ``key=value;key=value`` format. + :type operation_context: :class:`str` + + :raises ValueError: If the string is empty, contains control characters, or + does not match the required ``key=value`` format. + """ + + operation_context: str + + def __post_init__(self) -> None: + val = self.operation_context + if not val: + raise ValueError("operation_context must not be empty.") + if any(c in val for c in "\r\n\x00"): + raise ValueError("operation_context must not contain CR, LF, or NUL characters.") + if not _CONTEXT_PATTERN.match(val): + raise ValueError( + "operation_context must be semicolon-separated key=value pairs " + "(e.g. 'app=myapp/1.0;agent=claude-code'). " + "Keys and values may contain alphanumerics, hyphens, underscores, " + "dots, and slashes." + ) + @dataclass(frozen=True) class DataverseConfig: @@ -35,10 +73,10 @@ class DataverseConfig: When provided, all HTTP requests and responses are logged to timestamped ``.log`` files with automatic redaction of sensitive headers. :type log_config: ~PowerPlatform.Dataverse.core.log_config.LogConfig or None - :param operation_context: Optional caller-defined context string appended to the + :param operation_context: Optional caller-defined context object appended to the outbound ``User-Agent`` header as a parenthesized comment. Intended for - plugin/tool attribution (e.g. ``"app=dataverse-skills/1.2.1;skill=dv-data;agent=claude-code"``). - :type operation_context: :class:`str` or None + plugin/tool attribution. + :type operation_context: ~PowerPlatform.Dataverse.core.config.OperationContext or None """ language_code: int = 1033 @@ -50,7 +88,7 @@ class DataverseConfig: log_config: Optional["LogConfig"] = None - operation_context: Optional[str] = None + operation_context: Optional[OperationContext] = None @classmethod def from_env(cls) -> "DataverseConfig": diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index 3a1440bc..2cc3a3c1 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -206,10 +206,8 @@ def __init__( session=session, logger=self._http_logger, ) - ctx = self.config.operation_context - if ctx and any(c in ctx for c in "\r\n\x00"): - raise ValueError("operation_context must not contain CR, LF, or NUL characters.") - self._operation_context = ctx + ctx_obj = self.config.operation_context + self._operation_context = ctx_obj.operation_context if ctx_obj else None self._logical_to_entityset_cache: dict[str, str] = {} # Cache: normalized table_schema_name (lowercase) -> primary id attribute (e.g. accountid) self._logical_primaryid_cache: dict[str, str] = {} diff --git a/tests/unit/data/test_enum_optionset_payload.py b/tests/unit/data/test_enum_optionset_payload.py index adae4ba0..6287daf2 100644 --- a/tests/unit/data/test_enum_optionset_payload.py +++ b/tests/unit/data/test_enum_optionset_payload.py @@ -25,7 +25,7 @@ def __init__(self, language_code=1033): self.http_backoff = 0 self.http_timeout = 5 self.log_config = None - self.operation_context = None + self.operation_context = None # None or OperationContext object def _make_client(lang=1033): diff --git a/tests/unit/test_operation_context.py b/tests/unit/test_operation_context.py index 0a203fd8..b3891971 100644 --- a/tests/unit/test_operation_context.py +++ b/tests/unit/test_operation_context.py @@ -9,10 +9,51 @@ from azure.core.credentials import TokenCredential from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.core.config import DataverseConfig +from PowerPlatform.Dataverse.core.config import DataverseConfig, OperationContext from PowerPlatform.Dataverse.data._odata import _ODataClient, _USER_AGENT +class TestOperationContextValidation(unittest.TestCase): + """Tests for OperationContext format validation and PII rejection.""" + + def test_valid_single_pair(self): + ctx = OperationContext(operation_context="app=test/1.0") + self.assertEqual(ctx.operation_context, "app=test/1.0") + + def test_valid_multiple_pairs(self): + ctx = OperationContext(operation_context="app=test/1.0;skill=dv-data;agent=claude-code") + self.assertEqual(ctx.operation_context, "app=test/1.0;skill=dv-data;agent=claude-code") + + def test_valid_with_dots_slashes_hyphens(self): + ctx = OperationContext(operation_context="app=dataverse-skills/1.2.1") + self.assertEqual(ctx.operation_context, "app=dataverse-skills/1.2.1") + + def test_reject_empty(self): + with self.assertRaises(ValueError): + OperationContext(operation_context="") + + def test_reject_email(self): + with self.assertRaises(ValueError): + OperationContext(operation_context="myname@email.com") + + def test_reject_freeform_text(self): + with self.assertRaises(ValueError): + OperationContext(operation_context="my bank password is 1234") + + def test_reject_control_chars(self): + for bad in ["has\rnewline", "has\nnewline", "has\x00null"]: + with self.assertRaises(ValueError): + OperationContext(operation_context=bad) + + def test_reject_spaces(self): + with self.assertRaises(ValueError): + OperationContext(operation_context="app=my app") + + def test_reject_no_equals(self): + with self.assertRaises(ValueError): + OperationContext(operation_context="justaplainstring") + + class TestOperationContextConfig(unittest.TestCase): """Tests for operation_context on DataverseConfig.""" @@ -21,8 +62,9 @@ def test_default_is_none(self): self.assertIsNone(config.operation_context) def test_explicit_value(self): - config = DataverseConfig(operation_context="app=test/1.0;agent=claude-code") - self.assertEqual(config.operation_context, "app=test/1.0;agent=claude-code") + ctx = OperationContext(operation_context="app=test/1.0;agent=claude-code") + config = DataverseConfig(operation_context=ctx) + self.assertEqual(config.operation_context.operation_context, "app=test/1.0;agent=claude-code") def test_default_constructor_is_none(self): config = DataverseConfig() @@ -30,38 +72,47 @@ def test_default_constructor_is_none(self): class TestOperationContextClient(unittest.TestCase): - """Tests for operation_context kwarg on DataverseClient.""" + """Tests for context kwarg on DataverseClient.""" def setUp(self): self.mock_credential = MagicMock(spec=TokenCredential) self.base_url = "https://example.crm.dynamics.com" def test_kwarg_sets_config(self): + ctx = OperationContext(operation_context="app=test/1.0;skill=dv-data;agent=claude-code") client = DataverseClient( self.base_url, self.mock_credential, - operation_context="app=test/1.0;skill=dv-data;agent=claude-code", + context=ctx, + ) + self.assertEqual( + client._config.operation_context.operation_context, + "app=test/1.0;skill=dv-data;agent=claude-code", ) - self.assertEqual(client._config.operation_context, "app=test/1.0;skill=dv-data;agent=claude-code") def test_no_kwarg_leaves_config_default(self): client = DataverseClient(self.base_url, self.mock_credential) self.assertIsNone(client._config.operation_context) - def test_config_and_kwarg_raises(self): - config = DataverseConfig(operation_context="app=test/1.0") + def test_config_and_context_raises(self): + ctx = OperationContext(operation_context="app=test/1.0") + config = DataverseConfig(operation_context=ctx) with self.assertRaises(ValueError): DataverseClient( self.base_url, self.mock_credential, config=config, - operation_context="app=other/2.0", + context=OperationContext(operation_context="app=other/2.0"), ) def test_config_alone_works(self): - config = DataverseConfig(operation_context="app=test/1.0;agent=copilot") + ctx = OperationContext(operation_context="app=test/1.0;agent=copilot") + config = DataverseConfig(operation_context=ctx) client = DataverseClient(self.base_url, self.mock_credential, config=config) - self.assertEqual(client._config.operation_context, "app=test/1.0;agent=copilot") + self.assertEqual( + client._config.operation_context.operation_context, + "app=test/1.0;agent=copilot", + ) class TestOperationContextUserAgent(unittest.TestCase): @@ -80,11 +131,12 @@ def test_default_user_agent_unchanged(self): self.assertEqual(headers["User-Agent"], _USER_AGENT) def test_operation_context_appended(self): - ctx = "app=dataverse-skills/1.2.1;skill=dv-data;agent=claude-code" + ctx_str = "app=dataverse-skills/1.2.1;skill=dv-data;agent=claude-code" + ctx = OperationContext(operation_context=ctx_str) config = DataverseConfig(operation_context=ctx) odata = _ODataClient(self.dummy_auth, self.base_url, config=config) headers = odata._headers() - self.assertEqual(headers["User-Agent"], f"{_USER_AGENT} ({ctx})") + self.assertEqual(headers["User-Agent"], f"{_USER_AGENT} ({ctx_str})") def test_none_context_no_parentheses(self): config = DataverseConfig(operation_context=None) @@ -92,14 +144,6 @@ def test_none_context_no_parentheses(self): headers = odata._headers() self.assertNotIn("(", headers["User-Agent"]) - def test_empty_string_context_no_parentheses(self): - config = DataverseConfig(operation_context="") - odata = _ODataClient(self.dummy_auth, self.base_url, config=config) - headers = odata._headers() - self.assertNotIn("(", headers["User-Agent"]) - - def test_control_chars_rejected(self): - for bad in ["has\rnewline", "has\nnewline", "has\x00null"]: - config = DataverseConfig(operation_context=bad) - with self.assertRaises(ValueError): - _ODataClient(self.dummy_auth, self.base_url, config=config) + def test_empty_string_rejected_at_creation(self): + with self.assertRaises(ValueError): + OperationContext(operation_context="") From 5bfcf1e8f8363872db2f46c5ac252bfb79fc67aa Mon Sep 17 00:00:00 2001 From: shivamarora <122318437+arorashivam96@users.noreply.github.com> Date: Mon, 11 May 2026 18:52:35 -0700 Subject: [PATCH 7/7] rename OperationContext field to user_agent_context --- src/PowerPlatform/Dataverse/core/config.py | 8 ++-- src/PowerPlatform/Dataverse/data/_odata.py | 2 +- tests/unit/test_operation_context.py | 44 +++++++++++----------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/PowerPlatform/Dataverse/core/config.py b/src/PowerPlatform/Dataverse/core/config.py index db77af08..ed161048 100644 --- a/src/PowerPlatform/Dataverse/core/config.py +++ b/src/PowerPlatform/Dataverse/core/config.py @@ -32,17 +32,17 @@ class OperationContext: (e.g. ``"app=myapp/1.0;agent=claude-code"``). Free-form text, email addresses, and other potentially sensitive strings are rejected. - :param operation_context: Attribution string in ``key=value;key=value`` format. - :type operation_context: :class:`str` + :param user_agent_context: Attribution string in ``key=value;key=value`` format. + :type user_agent_context: :class:`str` :raises ValueError: If the string is empty, contains control characters, or does not match the required ``key=value`` format. """ - operation_context: str + user_agent_context: str def __post_init__(self) -> None: - val = self.operation_context + val = self.user_agent_context if not val: raise ValueError("operation_context must not be empty.") if any(c in val for c in "\r\n\x00"): diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index 2cc3a3c1..b6cdf29a 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -207,7 +207,7 @@ def __init__( logger=self._http_logger, ) ctx_obj = self.config.operation_context - self._operation_context = ctx_obj.operation_context if ctx_obj else None + self._operation_context = ctx_obj.user_agent_context if ctx_obj else None self._logical_to_entityset_cache: dict[str, str] = {} # Cache: normalized table_schema_name (lowercase) -> primary id attribute (e.g. accountid) self._logical_primaryid_cache: dict[str, str] = {} diff --git a/tests/unit/test_operation_context.py b/tests/unit/test_operation_context.py index b3891971..26db0a36 100644 --- a/tests/unit/test_operation_context.py +++ b/tests/unit/test_operation_context.py @@ -17,41 +17,41 @@ class TestOperationContextValidation(unittest.TestCase): """Tests for OperationContext format validation and PII rejection.""" def test_valid_single_pair(self): - ctx = OperationContext(operation_context="app=test/1.0") - self.assertEqual(ctx.operation_context, "app=test/1.0") + ctx = OperationContext(user_agent_context="app=test/1.0") + self.assertEqual(ctx.user_agent_context, "app=test/1.0") def test_valid_multiple_pairs(self): - ctx = OperationContext(operation_context="app=test/1.0;skill=dv-data;agent=claude-code") - self.assertEqual(ctx.operation_context, "app=test/1.0;skill=dv-data;agent=claude-code") + ctx = OperationContext(user_agent_context="app=test/1.0;skill=dv-data;agent=claude-code") + self.assertEqual(ctx.user_agent_context, "app=test/1.0;skill=dv-data;agent=claude-code") def test_valid_with_dots_slashes_hyphens(self): - ctx = OperationContext(operation_context="app=dataverse-skills/1.2.1") - self.assertEqual(ctx.operation_context, "app=dataverse-skills/1.2.1") + ctx = OperationContext(user_agent_context="app=dataverse-skills/1.2.1") + self.assertEqual(ctx.user_agent_context, "app=dataverse-skills/1.2.1") def test_reject_empty(self): with self.assertRaises(ValueError): - OperationContext(operation_context="") + OperationContext(user_agent_context="") def test_reject_email(self): with self.assertRaises(ValueError): - OperationContext(operation_context="myname@email.com") + OperationContext(user_agent_context="myname@email.com") def test_reject_freeform_text(self): with self.assertRaises(ValueError): - OperationContext(operation_context="my bank password is 1234") + OperationContext(user_agent_context="my bank password is 1234") def test_reject_control_chars(self): for bad in ["has\rnewline", "has\nnewline", "has\x00null"]: with self.assertRaises(ValueError): - OperationContext(operation_context=bad) + OperationContext(user_agent_context=bad) def test_reject_spaces(self): with self.assertRaises(ValueError): - OperationContext(operation_context="app=my app") + OperationContext(user_agent_context="app=my app") def test_reject_no_equals(self): with self.assertRaises(ValueError): - OperationContext(operation_context="justaplainstring") + OperationContext(user_agent_context="justaplainstring") class TestOperationContextConfig(unittest.TestCase): @@ -62,9 +62,9 @@ def test_default_is_none(self): self.assertIsNone(config.operation_context) def test_explicit_value(self): - ctx = OperationContext(operation_context="app=test/1.0;agent=claude-code") + ctx = OperationContext(user_agent_context="app=test/1.0;agent=claude-code") config = DataverseConfig(operation_context=ctx) - self.assertEqual(config.operation_context.operation_context, "app=test/1.0;agent=claude-code") + self.assertEqual(config.operation_context.user_agent_context, "app=test/1.0;agent=claude-code") def test_default_constructor_is_none(self): config = DataverseConfig() @@ -79,14 +79,14 @@ def setUp(self): self.base_url = "https://example.crm.dynamics.com" def test_kwarg_sets_config(self): - ctx = OperationContext(operation_context="app=test/1.0;skill=dv-data;agent=claude-code") + ctx = OperationContext(user_agent_context="app=test/1.0;skill=dv-data;agent=claude-code") client = DataverseClient( self.base_url, self.mock_credential, context=ctx, ) self.assertEqual( - client._config.operation_context.operation_context, + client._config.operation_context.user_agent_context, "app=test/1.0;skill=dv-data;agent=claude-code", ) @@ -95,22 +95,22 @@ def test_no_kwarg_leaves_config_default(self): self.assertIsNone(client._config.operation_context) def test_config_and_context_raises(self): - ctx = OperationContext(operation_context="app=test/1.0") + ctx = OperationContext(user_agent_context="app=test/1.0") config = DataverseConfig(operation_context=ctx) with self.assertRaises(ValueError): DataverseClient( self.base_url, self.mock_credential, config=config, - context=OperationContext(operation_context="app=other/2.0"), + context=OperationContext(user_agent_context="app=other/2.0"), ) def test_config_alone_works(self): - ctx = OperationContext(operation_context="app=test/1.0;agent=copilot") + ctx = OperationContext(user_agent_context="app=test/1.0;agent=copilot") config = DataverseConfig(operation_context=ctx) client = DataverseClient(self.base_url, self.mock_credential, config=config) self.assertEqual( - client._config.operation_context.operation_context, + client._config.operation_context.user_agent_context, "app=test/1.0;agent=copilot", ) @@ -132,7 +132,7 @@ def test_default_user_agent_unchanged(self): def test_operation_context_appended(self): ctx_str = "app=dataverse-skills/1.2.1;skill=dv-data;agent=claude-code" - ctx = OperationContext(operation_context=ctx_str) + ctx = OperationContext(user_agent_context=ctx_str) config = DataverseConfig(operation_context=ctx) odata = _ODataClient(self.dummy_auth, self.base_url, config=config) headers = odata._headers() @@ -146,4 +146,4 @@ def test_none_context_no_parentheses(self): def test_empty_string_rejected_at_creation(self): with self.assertRaises(ValueError): - OperationContext(operation_context="") + OperationContext(user_agent_context="")