Skip to content
Merged
9 changes: 8 additions & 1 deletion .azdo/ci-pr.yaml
Comment thread
arorashivam96 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Azure DevOps PR validation pipeline
# Matches GitHub Actions workflow for consistency

Expand Down Expand Up @@ -39,7 +39,14 @@
versionSpec: '3.12'
addToPath: true
displayName: 'Use Python 3.12'


# 1ES managed pools have no public PyPI egress; route pip through the
# internal Azure Artifacts feed (PyPI upstream), same as the Official pipeline.
- task: PipAuthenticate@1
inputs:
artifactFeeds: 'OneCRM/DPX-Tools-Upstream'
displayName: 'Authenticate pip to internal feed'

- script: |
python -m pip install --upgrade pip
python -m pip install flake8 black build diff-cover
Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ df = results.to_dataframe() # same rows as a pandas DataFrame

### Define and evolve schema

`client.tables` creates and inspects tables, columns, relationships, and alternate keys. Column types are simple strings (`"string"`, `"int"`, `"decimal"`, `"money"`, `"datetime"`, `"bool"`, `"memo"`); pass an `IntEnum` subclass to create a choice column. `tables.get` returns `None` when the table does not exist, which makes schema setup idempotent. Every table gets a primary name column automatically — `<prefix>_Name` unless you pass `primary_column` — so do not list it in `columns`.
`client.tables` creates and inspects tables, columns, relationships, and alternate keys. Column types are simple strings (`"string"`, `"int"`, `"decimal"`, `"money"`, `"datetime"`, `"bool"`, `"memo"`); pass an `IntEnum` subclass to create a choice column, or a dict spec (e.g. `{"type": "memo", "max_length": 2000}`) to set constraints like length, range, format, required level, or display name. `tables.get` returns `None` when the table does not exist, which makes schema setup idempotent. Every table gets a primary name column automatically — `<prefix>_Name` unless you pass `primary_column` — so do not list it in `columns`.
Comment thread
arorashivam96 marked this conversation as resolved.

```python
# Create a custom table with typed columns
Expand All @@ -136,6 +136,30 @@ client.tables.create_lookup_field(
)
```

Columns can carry constraints, be updated in place, and be read back with their
type-specific metadata in a single request:

```python
# Create columns with constraints -- pass a dict spec instead of a bare type.
# Keys: max_length, min_value, max_value, precision, format, required, display_name.
client.tables.create("new_Feedback", {
"new_Rating": {"type": "int", "min_value": 1, "max_value": 5},
"new_Comment": {"type": "memo", "max_length": 2000, "display_name": "Comment"},
})

# Update an existing column's constraints (same override keys as create).
client.tables.update_column("new_Feedback", "new_Comment", {"max_length": 4000})
client.tables.update_columns("new_Feedback", {
"new_Rating": {"max_value": 10},
"new_Comment": {"display_name": "Customer Comment"},
})

# Read a column (or all columns) with typed fields in one request (typed=True).
col = client.tables.get_column("new_Feedback", "new_Comment", typed=True)
print(col["MaxLength"]) # -> 4000
cols = client.tables.list_columns("new_Feedback", typed=True)
```

Relationship methods take logical names, which are always the schema name in lowercase. For choice columns, many-to-many relationships, and alternate keys, see [Customize tables and columns](https://learn.microsoft.com/power-apps/developer/data-platform/sdk-python/metadata) and [Manage table relationships](https://learn.microsoft.com/power-apps/developer/data-platform/sdk-python/relationships).

### Work in bulk
Expand Down
4 changes: 2 additions & 2 deletions src/PowerPlatform/Dataverse/aio/core/_async_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,13 @@ async def _request(self, method: str, url: str, **kwargs: Any) -> _AsyncResponse
raise RuntimeError("No aiohttp.ClientSession set. Set _session before making requests.")

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

# Log outbound request once (before retry loop).
Expand Down
142 changes: 142 additions & 0 deletions src/PowerPlatform/Dataverse/aio/data/_async_odata.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
_USER_AGENT,
_DEFAULT_EXPECTED_STATUSES,
_RequestContext,
_TYPED_COLUMN_PROPERTIES,
)


Expand Down Expand Up @@ -920,6 +921,7 @@ async def _list_columns(
*,
select: Optional[List[str]] = None,
filter: Optional[str] = None,
typed: bool = False,
) -> List[Dict[str, Any]]:
"""List all attribute (column) definitions for a table.

Expand All @@ -942,6 +944,14 @@ async def _list_columns(
:raises MetadataError: If the table is not found.
:raises HttpError: If the Web API request fails.
"""
if typed:
if filter:
raise ValueError(
"The 'filter' argument is not supported with typed=True: RetrieveMetadataChanges "
"projects typed fields but does not accept an OData $filter. Use typed=False to "
"filter, or drop filter when typed=True."
)
return await self._retrieve_metadata_changes(table_schema_name, properties=select)
ent = await self._get_entity_by_table_schema_name(table_schema_name)
if not ent or not ent.get("MetadataId"):
raise MetadataError(
Expand All @@ -958,6 +968,138 @@ async def _list_columns(
r = await self._request("get", url, params=params)
return (r.json()).get("value", [])

async def _retrieve_metadata_changes(
self,
table_schema_name: str,
*,
attribute_logical_name: Optional[str] = None,
properties: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Return typed column metadata in one request (RetrieveMetadataChanges, #203)."""
props = properties or _TYPED_COLUMN_PROPERTIES
attr_query: Dict[str, Any] = {"Properties": {"PropertyNames": props}}
if attribute_logical_name:
attr_query["Criteria"] = {
"Conditions": [
{
"PropertyName": "LogicalName",
"ConditionOperator": "Equals",
"Value": {"Type": "System.String", "Value": attribute_logical_name.lower()},
}
]
}
query: Dict[str, Any] = {
"Properties": {"PropertyNames": ["LogicalName", "Attributes"]},
"Criteria": {
"Conditions": [
{
"PropertyName": "LogicalName",
"ConditionOperator": "Equals",
"Value": {"Type": "System.String", "Value": table_schema_name.lower()},
}
]
},
"AttributeQuery": attr_query,
}
url = f"{self.api}/RetrieveMetadataChanges(Query=@p1)"
r = await self._request_metadata_with_retry("get", url, params={"@p1": json.dumps(query, ensure_ascii=False)})
entities = (r.json()).get("EntityMetadata", [])
if not entities:
raise MetadataError(
f"Table '{table_schema_name}' not found.",
subcode=METADATA_TABLE_NOT_FOUND,
)
return entities[0].get("Attributes", []) or []

async def _get_column(
self,
table_schema_name: str,
column_name: str,
*,
typed: bool = False,
select: Optional[List[str]] = None,
) -> Optional[Dict[str, Any]]:
"""Return one column's metadata (typed via RetrieveMetadataChanges when ``typed``)."""
if typed:
attrs = await self._retrieve_metadata_changes(
table_schema_name, attribute_logical_name=column_name, properties=select
)
return attrs[0] if attrs else None
ent = await self._get_entity_by_table_schema_name(table_schema_name)
if not ent or not ent.get("MetadataId"):
raise MetadataError(
f"Table '{table_schema_name}' not found.",
subcode=METADATA_TABLE_NOT_FOUND,
)
metadata_id = ent["MetadataId"]
logical = self._escape_odata_quotes(column_name.lower())
url = f"{self.api}/EntityDefinitions({metadata_id})/Attributes(LogicalName='{logical}')"
params: Dict[str, str] = {}
if select:
params["$select"] = ",".join(select)
try:
r = await self._request("get", url, params=params)
return r.json()
except HttpError as err:
if getattr(err, "status_code", None) == 404:
return None
raise

async def _update_attribute(
self,
table_schema_name: str,
column_name: str,
overrides: Dict[str, Any],
) -> str:
"""Update constraints on an existing column: retrieve the full attr -> PUT it back (#202).

Follows the documented column-update contract -- ``PUT`` the *entire*
current attribute definition so untouched properties are preserved -- and
sends ``MSCRM.MergeLabels: true`` so localized labels in other languages
survive.
"""
self._validate_column_overrides(column_name, overrides)
ent = await self._get_entity_by_table_schema_name(table_schema_name)
if not ent or not ent.get("MetadataId"):
raise MetadataError(
f"Table '{table_schema_name}' not found.",
subcode=METADATA_TABLE_NOT_FOUND,
)
metadata_id = ent["MetadataId"]
logical = self._escape_odata_quotes(column_name.lower())
attr_url = f"{self.api}/EntityDefinitions({metadata_id})/Attributes(LogicalName='{logical}')"
try:
resp = await self._request("get", attr_url, params={"$select": "MetadataId,SchemaName"})
existing = resp.json()
except HttpError as err:
if getattr(err, "status_code", None) == 404:
raise MetadataError(
f"Column '{column_name}' not found on table '{table_schema_name}'.",
subcode=METADATA_COLUMN_NOT_FOUND,
) from err
raise
attr_metadata_id = existing.get("MetadataId")
odata_type = str(existing.get("@odata.type", "")).lstrip("#")
if not attr_metadata_id or not odata_type:
raise MetadataError(
f"Column '{column_name}' not found on table '{table_schema_name}'.",
subcode=METADATA_COLUMN_NOT_FOUND,
)
full = (await self._request("get", f"{attr_url}/{odata_type}")).json()
body: Dict[str, Any] = {k: v for k, v in full.items() if not str(k).startswith("@odata.")}
body["@odata.type"] = odata_type
body["MetadataId"] = attr_metadata_id
body.setdefault("SchemaName", existing.get("SchemaName", column_name))
self._apply_column_overrides(body, overrides)
req = _RawRequest(
method="PUT",
url=f"{self.api}/EntityDefinitions({metadata_id})/Attributes({attr_metadata_id})",
body=json.dumps(body, ensure_ascii=False),
headers={"MSCRM.MergeLabels": "true"},
)
await self._execute_raw(req)
return column_name

async def _wait_for_attribute_visibility(
self,
entity_set: str,
Expand Down
75 changes: 71 additions & 4 deletions src/PowerPlatform/Dataverse/aio/operations/async_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,13 @@ async def create(
(or ``"money"``), ``"float"`` (or ``"double"``), ``"datetime"``
(or ``"date"``), ``"bool"`` (or ``"boolean"``), ``"file"``, and
``Enum`` subclasses
(for local option sets).
(for local option sets). A value may instead be a dict that pairs
a base ``type`` with constraint overrides -- ``max_length``,
``min_value``, ``max_value``, ``precision``, ``format``,
``required`` (``"None"``/``"ApplicationRequired"``/``"Recommended"``),
and ``display_name`` -- e.g.
``{"type": "int", "min_value": 1, "max_value": 5}`` or
``{"type": "memo", "max_length": 2000, "display_name": "Comment"}``.
:type columns: :class:`dict`
:param solution: Optional solution unique name that should own the new
table. When omitted the table is created in the default solution.
Expand Down Expand Up @@ -705,6 +711,7 @@ async def list_columns(
*,
select: Optional[List[str]] = None,
filter: Optional[str] = None,
typed: bool = False,
) -> List[Dict[str, Any]]:
"""List all attribute (column) definitions for a table.

Expand All @@ -714,9 +721,15 @@ async def list_columns(
:param select: Optional list of property names to project via
``$select``. Values are passed as-is (PascalCase).
:type select: list[str] or None
:param filter: Optional OData ``$filter`` expression. For example,
``"AttributeType eq 'String'"`` returns only string columns.
:param filter: Optional OData ``$filter`` expression (only when
``typed=False``). For example, ``"AttributeType eq 'String'"``
returns only string columns.
:type filter: :class:`str` or None
:param typed: When ``True``, read via ``RetrieveMetadataChanges`` so
type-specific fields (``MaxLength``, ``MinValue``/``MaxValue``, ...)
come back in one request without the ``@odata.type`` URL cast.
``filter`` is not supported in this mode.
:type typed: :class:`bool`

:return: List of raw attribute metadata dictionaries.
:rtype: list[dict[str, typing.Any]]
Expand All @@ -725,6 +738,8 @@ async def list_columns(
If the table is not found.
:raises ~PowerPlatform.Dataverse.core.errors.HttpError:
If the Web API request fails.
:raises ValueError:
If ``filter`` is combined with ``typed=True``.

Example::

Expand All @@ -746,7 +761,59 @@ async def list_columns(
)
"""
async with self._client._scoped_odata() as od:
return await od._list_columns(table, select=select, filter=filter)
return await od._list_columns(table, select=select, filter=filter, typed=typed)

async def get_column(
self,
table: str,
column: str,
*,
typed: bool = False,
select: Optional[List[str]] = None,
) -> Optional[Dict[str, Any]]:
"""Return a single column's metadata, or ``None`` if it does not exist.

When ``typed`` is ``True``, reads via ``RetrieveMetadataChanges`` so
type-specific fields come back in one request (no ``@odata.type`` cast).
"""
async with self._client._scoped_odata() as od:
return await od._get_column(table, column, typed=typed, select=select)

async def update_column(
self,
table: str,
column: str,
spec: Dict[str, Any],
) -> str:
"""Update constraints on an existing column.

Accepts the same override keys as the dict column spec in
:meth:`create` (``max_length``, ``min_value``, ``max_value``,
``precision``, ``format``, ``required``, ``display_name``). The SDK
reads the current typed attribute and issues the update internally;
callers never pass an HTTP verb or ``@odata.type``.
"""
async with self._client._scoped_odata() as od:
return await od._update_attribute(table, column, spec)

async def update_columns(
self,
table: str,
columns: Dict[str, Dict[str, Any]],
) -> List[str]:
"""Update constraints on multiple existing columns."""
if not isinstance(columns, dict) or not columns:
raise TypeError("columns must be a non-empty dict of {column: overrides}")
updated: List[str] = []
async with self._client._scoped_odata() as od:
# Validate every spec up front so a bad entry can't leave the table
# partially modified by earlier successful updates.
for col, spec in columns.items():
od._validate_column_overrides(col, spec)
for col, spec in columns.items():
await od._update_attribute(table, col, spec)
updated.append(col)
return updated

# ------------------------------------------------- list_relationships

Expand Down
4 changes: 2 additions & 2 deletions src/PowerPlatform/Dataverse/core/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,14 @@ def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response:
:raises requests.exceptions.RequestException: If all retry attempts fail.
"""
# If no timeout is provided, use the user-specified default timeout if set;
# otherwise, apply per-method defaults (120s for POST/PATCH/DELETE, 10s for others).
# otherwise, apply per-method defaults (120s for POST/PUT/PATCH/DELETE, 10s for others).
if "timeout" not in kwargs:
if self.default_timeout is not None:
kwargs["timeout"] = self.default_timeout
else:
m = (method or "").lower()
kwargs["timeout"] = (
_TIMEOUT_WRITE_METHODS if m in ("post", "patch", "delete") else _TIMEOUT_READ_METHODS
_TIMEOUT_WRITE_METHODS if m in ("post", "put", "patch", "delete") else _TIMEOUT_READ_METHODS
)

# Log outbound request once (before retry loop).
Expand Down
2 changes: 2 additions & 0 deletions src/PowerPlatform/Dataverse/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,15 @@
_ALLOWED_KEYS = frozenset({"app", "skill", "agent"})
_ALLOWED_SKILLS = frozenset(
{
"dv-overview",
"dv-connect",
"dv-data",
"dv-query",
"dv-metadata",
"dv-solution",
"dv-admin",
"dv-security",
"erp-xpp",
"unknown",
}
)
Expand Down
Loading
Loading