Skip to content

Commit cf388d1

Browse files
committed
fix: address PR review - PUT write-timeout, column-not-found subcode, full-definition metadata PUT + MergeLabels, pre-validate update_columns
1 parent fe79ea3 commit cf388d1

13 files changed

Lines changed: 111 additions & 47 deletions

File tree

‎src/PowerPlatform/Dataverse/aio/core/_async_http.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,13 @@ async def _request(self, method: str, url: str, **kwargs: Any) -> _AsyncResponse
115115
raise RuntimeError("No aiohttp.ClientSession set. Set _session before making requests.")
116116

117117
# If no timeout is provided, use the user-specified default timeout if set;
118-
# otherwise, apply per-method defaults (120s for POST/PATCH/DELETE, 10s for others).
118+
# otherwise, apply per-method defaults (120s for POST/PUT/PATCH/DELETE, 10s for others).
119119
if "timeout" not in kwargs:
120120
if self.default_timeout is not None:
121121
t = self.default_timeout
122122
else:
123123
m = (method or "").lower()
124-
t = _TIMEOUT_WRITE_METHODS if m in ("post", "patch", "delete") else _TIMEOUT_READ_METHODS
124+
t = _TIMEOUT_WRITE_METHODS if m in ("post", "put", "patch", "delete") else _TIMEOUT_READ_METHODS
125125
kwargs["timeout"] = aiohttp.ClientTimeout(total=t)
126126

127127
# Log outbound request once (before retry loop).

‎src/PowerPlatform/Dataverse/aio/data/_async_odata.py‎

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
_USER_AGENT,
4545
_DEFAULT_EXPECTED_STATUSES,
4646
_RequestContext,
47-
_COLUMN_OVERRIDE_KEYS,
4847
_TYPED_COLUMN_PROPERTIES,
4948
)
5049

@@ -1046,12 +1045,14 @@ async def _update_attribute(
10461045
column_name: str,
10471046
overrides: Dict[str, Any],
10481047
) -> str:
1049-
"""Update constraints on an existing column: GET typed attr -> PUT + @odata.type (#202)."""
1050-
if not isinstance(overrides, dict) or not overrides:
1051-
raise TypeError("overrides must be a non-empty dict of column constraints")
1052-
unknown = set(overrides) - (_COLUMN_OVERRIDE_KEYS - {"type"})
1053-
if unknown:
1054-
raise ValueError(f"Unknown column constraint override(s) for '{column_name}': {sorted(unknown)}")
1048+
"""Update constraints on an existing column: retrieve the full attr -> PUT it back (#202).
1049+
1050+
Follows the documented column-update contract -- ``PUT`` the *entire*
1051+
current attribute definition so untouched properties are preserved -- and
1052+
sends ``MSCRM.MergeLabels: true`` so localized labels in other languages
1053+
survive.
1054+
"""
1055+
self._validate_column_overrides(column_name, overrides)
10551056
ent = await self._get_entity_by_table_schema_name(table_schema_name)
10561057
if not ent or not ent.get("MetadataId"):
10571058
raise MetadataError(
@@ -1068,26 +1069,27 @@ async def _update_attribute(
10681069
if getattr(err, "status_code", None) == 404:
10691070
raise MetadataError(
10701071
f"Column '{column_name}' not found on table '{table_schema_name}'.",
1071-
subcode=METADATA_TABLE_NOT_FOUND,
1072+
subcode=METADATA_COLUMN_NOT_FOUND,
10721073
) from err
10731074
raise
10741075
attr_metadata_id = existing.get("MetadataId")
10751076
odata_type = str(existing.get("@odata.type", "")).lstrip("#")
10761077
if not attr_metadata_id or not odata_type:
10771078
raise MetadataError(
10781079
f"Column '{column_name}' not found on table '{table_schema_name}'.",
1079-
subcode=METADATA_TABLE_NOT_FOUND,
1080+
subcode=METADATA_COLUMN_NOT_FOUND,
10801081
)
1081-
body: Dict[str, Any] = {
1082-
"@odata.type": odata_type,
1083-
"MetadataId": attr_metadata_id,
1084-
"SchemaName": existing.get("SchemaName", column_name),
1085-
}
1082+
full = (await self._request("get", f"{attr_url}/{odata_type}")).json()
1083+
body: Dict[str, Any] = {k: v for k, v in full.items() if not str(k).startswith("@odata.")}
1084+
body["@odata.type"] = odata_type
1085+
body["MetadataId"] = attr_metadata_id
1086+
body.setdefault("SchemaName", existing.get("SchemaName", column_name))
10861087
self._apply_column_overrides(body, overrides)
10871088
req = _RawRequest(
10881089
method="PUT",
10891090
url=f"{self.api}/EntityDefinitions({metadata_id})/Attributes({attr_metadata_id})",
10901091
body=json.dumps(body, ensure_ascii=False),
1092+
headers={"MSCRM.MergeLabels": "true"},
10911093
)
10921094
await self._execute_raw(req)
10931095
return column_name

‎src/PowerPlatform/Dataverse/aio/operations/async_tables.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,10 @@ async def update_columns(
798798
raise TypeError("columns must be a non-empty dict of {column: overrides}")
799799
updated: List[str] = []
800800
async with self._client._scoped_odata() as od:
801+
# Validate every spec up front so a bad entry can't leave the table
802+
# partially modified by earlier successful updates.
803+
for col, spec in columns.items():
804+
od._validate_column_overrides(col, spec)
801805
for col, spec in columns.items():
802806
await od._update_attribute(table, col, spec)
803807
updated.append(col)

‎src/PowerPlatform/Dataverse/core/_http.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,14 @@ def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response:
7676
:raises requests.exceptions.RequestException: If all retry attempts fail.
7777
"""
7878
# If no timeout is provided, use the user-specified default timeout if set;
79-
# otherwise, apply per-method defaults (120s for POST/PATCH/DELETE, 10s for others).
79+
# otherwise, apply per-method defaults (120s for POST/PUT/PATCH/DELETE, 10s for others).
8080
if "timeout" not in kwargs:
8181
if self.default_timeout is not None:
8282
kwargs["timeout"] = self.default_timeout
8383
else:
8484
m = (method or "").lower()
8585
kwargs["timeout"] = (
86-
_TIMEOUT_WRITE_METHODS if m in ("post", "patch", "delete") else _TIMEOUT_READ_METHODS
86+
_TIMEOUT_WRITE_METHODS if m in ("post", "put", "patch", "delete") else _TIMEOUT_READ_METHODS
8787
)
8888

8989
# Log outbound request once (before retry loop).

‎src/PowerPlatform/Dataverse/data/_odata.py‎

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@
4545
_USER_AGENT,
4646
_DEFAULT_EXPECTED_STATUSES,
4747
_RequestContext,
48-
_COLUMN_OVERRIDE_KEYS,
4948
_TYPED_COLUMN_PROPERTIES,
5049
)
5150

@@ -1031,18 +1030,17 @@ def _update_attribute(
10311030
column_name: str,
10321031
overrides: Dict[str, Any],
10331032
) -> str:
1034-
"""Update constraints on an existing column: GET typed attr -> PUT + @odata.type (#202).
1035-
1036-
Hides the PUT-not-PATCH metadata-update contract and the derived
1037-
``@odata.type`` discriminator. GETs the existing attribute to learn its
1038-
derived type + ``MetadataId``, applies the override spec (same shape as
1039-
create; see ``_apply_column_overrides``), then PUTs a merge payload.
1033+
"""Update constraints on an existing column: retrieve the full attr -> PUT it back (#202).
1034+
1035+
Follows the documented column-update contract: the attribute is updated
1036+
with ``PUT`` carrying the *entire* current definition (not a sparse body),
1037+
so properties the caller did not touch are preserved. We learn the derived
1038+
``@odata.type``, retrieve the complete concrete attribute via the type
1039+
cast, apply the override spec (same shape as create; see
1040+
``_apply_column_overrides``), and ``PUT`` the merged definition back with
1041+
``MSCRM.MergeLabels: true`` so localized labels in other languages survive.
10401042
"""
1041-
if not isinstance(overrides, dict) or not overrides:
1042-
raise TypeError("overrides must be a non-empty dict of column constraints")
1043-
unknown = set(overrides) - (_COLUMN_OVERRIDE_KEYS - {"type"})
1044-
if unknown:
1045-
raise ValueError(f"Unknown column constraint override(s) for '{column_name}': {sorted(unknown)}")
1043+
self._validate_column_overrides(column_name, overrides)
10461044
ent = self._get_entity_by_table_schema_name(table_schema_name)
10471045
if not ent or not ent.get("MetadataId"):
10481046
raise MetadataError(
@@ -1058,26 +1056,30 @@ def _update_attribute(
10581056
if getattr(err, "status_code", None) == 404:
10591057
raise MetadataError(
10601058
f"Column '{column_name}' not found on table '{table_schema_name}'.",
1061-
subcode=METADATA_TABLE_NOT_FOUND,
1059+
subcode=METADATA_COLUMN_NOT_FOUND,
10621060
) from err
10631061
raise
10641062
attr_metadata_id = existing.get("MetadataId")
10651063
odata_type = str(existing.get("@odata.type", "")).lstrip("#")
10661064
if not attr_metadata_id or not odata_type:
10671065
raise MetadataError(
10681066
f"Column '{column_name}' not found on table '{table_schema_name}'.",
1069-
subcode=METADATA_TABLE_NOT_FOUND,
1067+
subcode=METADATA_COLUMN_NOT_FOUND,
10701068
)
1071-
body: Dict[str, Any] = {
1072-
"@odata.type": odata_type,
1073-
"MetadataId": attr_metadata_id,
1074-
"SchemaName": existing.get("SchemaName", column_name),
1075-
}
1069+
# Retrieve the COMPLETE concrete definition (via the @odata.type cast) so the
1070+
# PUT round-trips every property the caller did not override, per the
1071+
# documented column-update contract.
1072+
full = self._request("get", f"{attr_url}/{odata_type}").json()
1073+
body: Dict[str, Any] = {k: v for k, v in full.items() if not str(k).startswith("@odata.")}
1074+
body["@odata.type"] = odata_type
1075+
body["MetadataId"] = attr_metadata_id
1076+
body.setdefault("SchemaName", existing.get("SchemaName", column_name))
10761077
self._apply_column_overrides(body, overrides)
10771078
req = _RawRequest(
10781079
method="PUT",
10791080
url=f"{self.api}/EntityDefinitions({metadata_id})/Attributes({attr_metadata_id})",
10801081
body=json.dumps(body, ensure_ascii=False),
1082+
headers={"MSCRM.MergeLabels": "true"},
10811083
)
10821084
self._execute_raw(req)
10831085
return column_name

‎src/PowerPlatform/Dataverse/data/_odata_base.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,14 @@ def _attribute_payload(
504504
self._apply_column_overrides(payload, overrides)
505505
return payload
506506

507+
def _validate_column_overrides(self, column_name: str, overrides: Dict[str, Any]) -> None:
508+
"""Validate a column-override spec locally (shape + known keys) before any I/O (#202)."""
509+
if not isinstance(overrides, dict) or not overrides:
510+
raise TypeError("overrides must be a non-empty dict of column constraints")
511+
unknown = set(overrides) - (_COLUMN_OVERRIDE_KEYS - {"type"})
512+
if unknown:
513+
raise ValueError(f"Unknown column constraint override(s) for '{column_name}': {sorted(unknown)}")
514+
507515
def _apply_column_overrides(self, payload: Dict[str, Any], overrides: Dict[str, Any]) -> None:
508516
"""Apply per-column constraint overrides onto a base attribute payload (#194)."""
509517
if "max_length" in overrides:

‎src/PowerPlatform/Dataverse/operations/tables.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,10 @@ def update_columns(
828828
raise TypeError("columns must be a non-empty dict of {column: overrides}")
829829
updated: List[str] = []
830830
with self._client._scoped_odata() as od:
831+
# Validate every spec up front so a bad entry can't leave the table
832+
# partially modified by earlier successful updates.
833+
for col, spec in columns.items():
834+
od._validate_column_overrides(col, spec)
831835
for col, spec in columns.items():
832836
od._update_attribute(table, col, spec)
833837
updated.append(col)

‎tests/unit/aio/core/test_async_http.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,13 @@ async def test_delete_uses_120s_default_timeout(self):
6767
_, kwargs = session.request.call_args
6868
assert kwargs["timeout"].total == 120
6969

70-
async def test_put_uses_10s_default_timeout(self):
71-
"""PUT requests use 10 s default (only POST/DELETE get 120 s)."""
70+
async def test_put_uses_120s_default_timeout(self):
71+
"""PUT requests use 120 s default (it is a write method, like POST/PATCH/DELETE)."""
7272
session = _make_session()
7373
client = _AsyncHttpClient(retries=1, session=session)
7474
await client._request("put", "https://example.com/data")
7575
_, kwargs = session.request.call_args
76-
assert kwargs["timeout"].total == 10
76+
assert kwargs["timeout"].total == 120
7777

7878
async def test_patch_uses_120s_default_timeout(self):
7979
"""PATCH requests use 120 s default (same as POST/DELETE)."""

‎tests/unit/aio/data/test_async_odata_internal.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from PowerPlatform.Dataverse.aio.data._async_odata import _AsyncODataClient
1414
from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError, ValidationError
15+
from PowerPlatform.Dataverse.core._error_codes import METADATA_COLUMN_NOT_FOUND
1516

1617
# ---------------------------------------------------------------------------
1718
# Helpers
@@ -2007,10 +2008,14 @@ async def test_update_issues_put_with_odata_type_and_override(self):
20072008
await client._update_attribute(
20082009
"new_Feedback", "new_Comment", {"max_length": 4000, "display_name": "Customer Comment"}
20092010
)
2011+
# Two GETs: lightweight (learn @odata.type) then the full concrete definition via the type cast.
2012+
assert client._request.await_count == 2
2013+
assert "Microsoft.Dynamics.CRM.MemoAttributeMetadata" in client._request.call_args_list[1].args[1]
20102014
client._execute_raw.assert_awaited_once()
20112015
req = client._execute_raw.call_args.args[0]
20122016
assert req.method == "PUT"
20132017
assert "Attributes(attr-1)" in req.url
2018+
assert req.headers == {"MSCRM.MergeLabels": "true"}
20142019
body = json.loads(req.body)
20152020
assert body["@odata.type"] == "Microsoft.Dynamics.CRM.MemoAttributeMetadata"
20162021
assert body["MetadataId"] == "attr-1"
@@ -2036,8 +2041,9 @@ async def test_update_table_not_found_raises(self):
20362041
async def test_update_column_get_404_raises_metadata_error(self):
20372042
client = self._client()
20382043
client._request = AsyncMock(side_effect=HttpError("not found", 404))
2039-
with pytest.raises(MetadataError):
2044+
with pytest.raises(MetadataError) as ei:
20402045
await client._update_attribute("new_Feedback", "ghost", {"max_length": 10})
2046+
assert ei.value.subcode == METADATA_COLUMN_NOT_FOUND
20412047
client._execute_raw.assert_not_awaited()
20422048

20432049
async def test_update_column_get_500_propagates(self):
@@ -2049,6 +2055,7 @@ async def test_update_column_get_500_propagates(self):
20492055
async def test_update_column_missing_odata_type_raises(self):
20502056
client = self._client()
20512057
client._request = AsyncMock(return_value=_resp(json_data={"SchemaName": "new_Comment"}))
2052-
with pytest.raises(MetadataError):
2058+
with pytest.raises(MetadataError) as ei:
20532059
await client._update_attribute("new_Feedback", "new_Comment", {"max_length": 10})
2060+
assert ei.value.subcode == METADATA_COLUMN_NOT_FOUND
20542061
client._execute_raw.assert_not_awaited()

‎tests/unit/aio/test_async_tables.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,13 +337,28 @@ async def test_update_column(self, async_client, mock_od):
337337

338338
async def test_update_columns(self, async_client, mock_od):
339339
"""update_columns() updates each column and returns the names in order."""
340+
from unittest.mock import MagicMock
341+
342+
mock_od._validate_column_overrides = MagicMock() # sync helper, not a coroutine
340343
result = await async_client.tables.update_columns(
341344
"new_Feedback",
342345
{"new_Comment": {"max_length": 4000}, "new_Rating": {"max_value": 10}},
343346
)
344347
assert result == ["new_Comment", "new_Rating"]
345348
assert mock_od._update_attribute.await_count == 2
346349

350+
async def test_update_columns_validates_all_before_updating(self, async_client, mock_od):
351+
"""A bad spec is rejected before ANY column is updated (no partial update)."""
352+
from unittest.mock import MagicMock
353+
import pytest
354+
355+
mock_od._validate_column_overrides = MagicMock(side_effect=[None, TypeError("empty")])
356+
with pytest.raises(TypeError):
357+
await async_client.tables.update_columns(
358+
"new_Feedback", {"new_First": {"max_length": 100}, "new_Second": {}}
359+
)
360+
mock_od._update_attribute.assert_not_awaited()
361+
347362
async def test_update_columns_empty_raises(self, async_client, mock_od):
348363
"""update_columns() rejects an empty mapping before touching the client."""
349364
import pytest

0 commit comments

Comments
 (0)