Skip to content

Commit b5b4ffa

Browse files
riverma12Copilot
andcommitted
FinOps SDK Step 2: paginated list iterator + metadata reads
- records.list(entity_set, *, filter, select, expand, orderby, top, page_size, cross_company): generator over @odata.nextLink with client-side top cap, Prefer: odata.maxpagesize header, and the FinOps-specific cross-company=true switch (verified live against Aurora aurorabapenvdc9e7 -- per-company default returns 0 rows for CustomerGroups, cross_company=True paginates correctly). - New operations.metadata namespace (read-only): list_data_entities / get_data_entity list_public_entities / get_public_entity list_public_enumerations / get_public_enumeration URLs live at /metadata/* (NOT under /data/). Metadata controllers reject \ with HTTP 400, so the SDK intentionally does not expose select on these methods. \ is sent server-side but enforced client-side because the controllers currently ignore it. - 20 new unit tests (test_records_list.py: 11, test_metadata.py: 9) covering pagination, query-option emission, top short-circuit, cross-company flag, OData single-quote escaping, empty-name validation, and nextLink follow-through. Full pytest sweep: 1369 passed, 0 failed. - Live verified end-to-end against https://aurorabapenvdc9e7.operations.int.dynamics.com (legal entity 'dat'): all six smoketest phases green including typed FinOpsNotFoundError mapping for missing metadata names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e9647d8 commit b5b4ffa

7 files changed

Lines changed: 676 additions & 4 deletions

File tree

src/PowerPlatform/FinOps/client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from ._auth import TokenProvider
88
from ._http import HttpClient
9-
from .operations import RecordOperations
9+
from .operations import MetadataOperations, RecordOperations
1010

1111
if TYPE_CHECKING: # pragma: no cover
1212
from azure.core.credentials import TokenCredential
@@ -65,6 +65,7 @@ def __init__(
6565

6666
# Operation namespaces.
6767
self.records = RecordOperations(self)
68+
self.metadata = MetadataOperations(self)
6869

6970
# -- accessors ------------------------------------------------------
7071

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Operations namespaces for the FinOps SDK."""
2+
from .metadata import MetadataOperations
23
from .records import RecordOperations
34

4-
__all__ = ["RecordOperations"]
5+
__all__ = ["MetadataOperations", "RecordOperations"]
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""Metadata read operations for the FinOps SDK.
2+
3+
This is **Step 2** of the FinOps SDK roadmap captured in
4+
``FinOps-SDK-Plan.docx``. It wraps the read-only metadata surface exposed
5+
by the FinOps Platform under ``/metadata/...``:
6+
7+
============================== ==============================================
8+
HTTP SDK call
9+
============================== ==============================================
10+
``GET /metadata/DataEntities`` ``client.metadata.list_data_entities(...)``
11+
``GET /metadata/DataEntities('N')`` ``client.metadata.get_data_entity('N')``
12+
``GET /metadata/PublicEntities`` ``client.metadata.list_public_entities(...)``
13+
``GET /metadata/PublicEntities('N')`` ``client.metadata.get_public_entity('N')``
14+
``GET /metadata/PublicEnumerations`` ``client.metadata.list_public_enumerations()``
15+
============================== ==============================================
16+
17+
These verbs are backed by ``DataEntitiesController`` and sibling controllers in
18+
the FinOps Platform under
19+
``Source/Platform/Integration/Services/WebApi/Metadata/Source/Controllers/``.
20+
21+
These endpoints are read-only by design — there is no runtime metadata-write
22+
API in FinOps (schema is authored in X++ and built into the model layer; see
23+
``FinOps-SDK-Plan.docx`` §7).
24+
"""
25+
from __future__ import annotations
26+
27+
from typing import TYPE_CHECKING, Iterator, Optional
28+
from urllib.parse import quote
29+
30+
from ..errors import FinOpsError
31+
32+
if TYPE_CHECKING: # pragma: no cover
33+
from ..client import FinOpsClient
34+
35+
36+
class MetadataOperations:
37+
"""Read-only metadata operations on the FinOps ``/metadata`` surface.
38+
39+
Obtain via ``FinOpsClient.metadata`` — do not instantiate directly.
40+
"""
41+
42+
def __init__(self, client: "FinOpsClient") -> None:
43+
self._client = client
44+
45+
# ------------------------------------------------------------------ #
46+
# /metadata/DataEntities #
47+
# ------------------------------------------------------------------ #
48+
def list_data_entities(
49+
self,
50+
*,
51+
filter: Optional[str] = None,
52+
top: Optional[int] = None,
53+
) -> Iterator[dict]:
54+
"""``GET /metadata/DataEntities`` — yield every public data entity descriptor.
55+
56+
Returns one row at a time, transparently following the
57+
``@odata.nextLink`` continuation token.
58+
59+
.. note::
60+
The metadata controllers do **not** support ``$select`` (the server
61+
replies HTTP 400). ``$top`` is accepted but currently ignored
62+
server-side, so this SDK enforces ``top`` as a client-side cap.
63+
"""
64+
yield from self._paginate("DataEntities", filter=filter, top=top)
65+
66+
def get_data_entity(self, name: str) -> dict:
67+
"""``GET /metadata/DataEntities('Name')`` — single entity descriptor."""
68+
if not name:
69+
raise ValueError("entity name is required")
70+
url = f"{self._metadata_url()}/DataEntities('{_escape(name)}')"
71+
return self._client._http.request("GET", url, expected=(200,)).json()
72+
73+
# ------------------------------------------------------------------ #
74+
# /metadata/PublicEntities #
75+
# ------------------------------------------------------------------ #
76+
def list_public_entities(
77+
self,
78+
*,
79+
filter: Optional[str] = None,
80+
top: Optional[int] = None,
81+
) -> Iterator[dict]:
82+
"""``GET /metadata/PublicEntities`` — yield every public entity (with column metadata).
83+
84+
See :meth:`list_data_entities` for ``$select``/``$top`` caveats.
85+
"""
86+
yield from self._paginate("PublicEntities", filter=filter, top=top)
87+
88+
def get_public_entity(self, name: str) -> dict:
89+
"""``GET /metadata/PublicEntities('Name')`` — single entity with column metadata."""
90+
if not name:
91+
raise ValueError("entity name is required")
92+
url = f"{self._metadata_url()}/PublicEntities('{_escape(name)}')"
93+
return self._client._http.request("GET", url, expected=(200,)).json()
94+
95+
# ------------------------------------------------------------------ #
96+
# /metadata/PublicEnumerations #
97+
# ------------------------------------------------------------------ #
98+
def list_public_enumerations(
99+
self,
100+
*,
101+
filter: Optional[str] = None,
102+
top: Optional[int] = None,
103+
) -> Iterator[dict]:
104+
"""``GET /metadata/PublicEnumerations`` — yield every public enum descriptor."""
105+
yield from self._paginate("PublicEnumerations", filter=filter, top=top)
106+
107+
def get_public_enumeration(self, name: str) -> dict:
108+
"""``GET /metadata/PublicEnumerations('Name')`` — single enum descriptor."""
109+
if not name:
110+
raise ValueError("enumeration name is required")
111+
url = f"{self._metadata_url()}/PublicEnumerations('{_escape(name)}')"
112+
return self._client._http.request("GET", url, expected=(200,)).json()
113+
114+
# ------------------------------------------------------------------ #
115+
# internals #
116+
# ------------------------------------------------------------------ #
117+
def _metadata_url(self) -> str:
118+
return f"{self._client.environment_url}/metadata"
119+
120+
def _paginate(
121+
self,
122+
collection: str,
123+
*,
124+
filter: Optional[str] = None,
125+
top: Optional[int] = None,
126+
) -> Iterator[dict]:
127+
params: dict = {}
128+
if filter:
129+
params["$filter"] = filter
130+
if top is not None:
131+
if top <= 0:
132+
return
133+
# Server currently ignores $top on /metadata/* but sending it is
134+
# harmless and lets us upgrade transparently if/when it lands.
135+
params["$top"] = str(top)
136+
137+
url: Optional[str] = f"{self._metadata_url()}/{collection}"
138+
request_params: Optional[dict] = params or None
139+
yielded = 0
140+
while url:
141+
resp = self._client._http.request(
142+
"GET", url, params=request_params, expected=(200,)
143+
)
144+
payload = resp.json()
145+
for row in payload.get("value", []):
146+
if top is not None and yielded >= top:
147+
return
148+
yield row
149+
yielded += 1
150+
url = payload.get("@odata.nextLink")
151+
request_params = None
152+
153+
154+
def _escape(name: str) -> str:
155+
if "'" in name:
156+
# OData v4 string literal escape: single quotes are doubled.
157+
return name.replace("'", "''")
158+
return name

src/PowerPlatform/FinOps/operations/records.py

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434

3535
from datetime import date, datetime
3636
from decimal import Decimal
37-
from typing import TYPE_CHECKING, Any, Mapping, Optional, Union
37+
from typing import TYPE_CHECKING, Any, Iterator, Mapping, Optional, Sequence, Union
3838
from urllib.parse import quote
3939

4040
from ..errors import FinOpsError
@@ -121,8 +121,100 @@ def get(
121121
return resp.json()
122122

123123
# ------------------------------------------------------------------ #
124-
# UPDATE #
124+
# LIST (paginated) #
125125
# ------------------------------------------------------------------ #
126+
def list(
127+
self,
128+
entity_set: str,
129+
*,
130+
filter: Optional[str] = None,
131+
select: Optional[Sequence[str]] = None,
132+
expand: Optional[Sequence[str]] = None,
133+
orderby: Optional[Union[str, Sequence[str]]] = None,
134+
top: Optional[int] = None,
135+
page_size: Optional[int] = None,
136+
cross_company: bool = False,
137+
) -> Iterator[dict]:
138+
"""``GET /data/{entity_set}`` — yield rows lazily across all pages.
139+
140+
Transparently follows the ``@odata.nextLink`` continuation token
141+
emitted by FinOps OData and yields one row dict at a time. Stops
142+
after ``top`` rows when given (server is told via ``$top``; client
143+
also caps just in case the server ignores it).
144+
145+
Parameters
146+
----------
147+
entity_set:
148+
FinOps OData entity set name (e.g. ``"CustomersV3"``).
149+
filter:
150+
Raw OData ``$filter`` expression. Callers are responsible for
151+
quoting; the SDK will not try to parse it. A typed query builder
152+
is on the roadmap (see ``FinOps-SDK-Plan.docx`` §8 Phase 2).
153+
select:
154+
Column names for ``$select``.
155+
expand:
156+
Navigation properties for ``$expand``.
157+
orderby:
158+
Either a single OData ordering clause (``"CreatedDateTime desc"``)
159+
or a list of them.
160+
top:
161+
Hard cap on rows. Sent server-side as ``$top`` and enforced
162+
client-side as a defensive stop.
163+
page_size:
164+
Optional ``Prefer: odata.maxpagesize=N`` hint to ask the server
165+
for smaller pages — useful when the dataset is large and the
166+
caller is paging memory-sensitively.
167+
cross_company:
168+
When ``True``, sends the FinOps-specific ``cross-company=true``
169+
query parameter so rows from every legal entity (``dataAreaId``)
170+
are returned. Default is ``False``, which mirrors the FinOps
171+
OData default of scoping to the caller's default company.
172+
173+
Yields
174+
------
175+
dict
176+
One OData row at a time.
177+
"""
178+
params: dict = {}
179+
if cross_company:
180+
params["cross-company"] = "true"
181+
if filter:
182+
params["$filter"] = filter
183+
if select:
184+
params["$select"] = ",".join(select)
185+
if expand:
186+
params["$expand"] = ",".join(expand)
187+
if orderby:
188+
params["$orderby"] = orderby if isinstance(orderby, str) else ",".join(orderby)
189+
if top is not None:
190+
if top <= 0:
191+
return
192+
params["$top"] = str(top)
193+
194+
headers: Optional[dict] = None
195+
if page_size is not None:
196+
if page_size <= 0:
197+
raise ValueError("page_size must be positive")
198+
headers = {"Prefer": f"odata.maxpagesize={int(page_size)}"}
199+
200+
url: Optional[str] = self._collection_url(entity_set)
201+
request_params: Optional[dict] = params or None
202+
yielded = 0
203+
while url:
204+
resp = self._client._http.request(
205+
"GET", url, params=request_params, headers=headers, expected=(200,)
206+
)
207+
payload = resp.json()
208+
for row in payload.get("value", []):
209+
if top is not None and yielded >= top:
210+
return
211+
yield row
212+
yielded += 1
213+
url = payload.get("@odata.nextLink")
214+
# The nextLink already encodes all of the original $-options.
215+
request_params = None
216+
217+
126218
def update(
127219
self,
128220
entity_set: str,

step2_smoketest.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Step 2 live smoketest — list pagination + metadata reads.
2+
3+
Run from the C:\\finops-crud-demo .venv with the SDK installed editable.
4+
Requires: az login (see README), then `python step2_smoketest.py`.
5+
"""
6+
from __future__ import annotations
7+
8+
import os
9+
import sys
10+
11+
from azure.identity import AzureCliCredential
12+
13+
from PowerPlatform.FinOps import FinOpsClient
14+
15+
16+
def main() -> int:
17+
env_url = os.environ.get(
18+
"FNO_ENV_URL", "https://aurorabapenvdc9e7.operations.int.dynamics.com"
19+
)
20+
tenant_id = os.environ.get(
21+
"FNO_TENANT_ID", "4abc24ea-2d0b-4011-87d4-3de32ca1e9cc"
22+
)
23+
24+
print("=" * 72)
25+
print(f" Step 2 smoketest against {env_url}")
26+
print("=" * 72)
27+
28+
cred = AzureCliCredential(tenant_id=tenant_id)
29+
with FinOpsClient(env_url, cred) as client:
30+
31+
# ---- records.list with $top + $select ----
32+
print("\n[1] records.list(LegalEntities, top=3, select=[LegalEntityId, Name])")
33+
rows = list(
34+
client.records.list(
35+
"LegalEntities",
36+
select=["LegalEntityId", "Name"],
37+
top=3,
38+
)
39+
)
40+
for r in rows:
41+
print(f" -> {r.get('LegalEntityId')!r} {r.get('Name')!r}")
42+
print(f" => yielded {len(rows)} rows (top=3)")
43+
44+
# ---- records.list paginating across many CustomerGroups ----
45+
print("\n[2] records.list(CustomerGroups, cross_company=True, page_size=10) iterate first 25")
46+
cg_iter = client.records.list(
47+
"CustomerGroups", cross_company=True, page_size=10
48+
)
49+
first25 = []
50+
for row in cg_iter:
51+
first25.append(row.get("CustomerGroupId"))
52+
if len(first25) >= 25:
53+
break
54+
print(f" -> first IDs: {first25[:10]} ...")
55+
print(f" => collected {len(first25)} rows across pages of 10")
56+
57+
# ---- metadata.list_data_entities ----
58+
print("\n[3] metadata.list_data_entities(top=5) (server ignores $top, SDK caps client-side)")
59+
de_rows = list(client.metadata.list_data_entities(top=5))
60+
for r in de_rows:
61+
print(f" -> {r.get('Name')} ({r.get('PublicEntityName')})")
62+
print(f" => yielded {len(de_rows)} rows")
63+
64+
# ---- metadata.get_data_entity for one we know exists ----
65+
# `Name` is the X++ entity name (e.g. `AbbreviationsEntity`), not the
66+
# public OData entity set name. We pick whatever name came back from
67+
# phase 3 above so the smoketest is self-bootstrapping.
68+
if not de_rows:
69+
print("\n[4] SKIPPED — phase 3 yielded no rows")
70+
else:
71+
target_name = de_rows[0]["Name"]
72+
print(f"\n[4] metadata.get_data_entity({target_name!r})")
73+
de = client.metadata.get_data_entity(target_name)
74+
print(
75+
f" -> Name={de.get('Name')!r} "
76+
f"PublicEntityName={de.get('PublicEntityName')!r} "
77+
f"IsReadOnly={de.get('IsReadOnly')}"
78+
)
79+
80+
# ---- typed 404 mapping for missing metadata name ----
81+
from PowerPlatform.FinOps.errors import FinOpsNotFoundError
82+
83+
print("\n[5] metadata.get_data_entity('NoSuchEntityXYZ') (expecting typed 404)")
84+
try:
85+
client.metadata.get_data_entity("NoSuchEntityXYZ")
86+
except FinOpsNotFoundError as exc:
87+
print(f" -> typed FinOpsNotFoundError raised: status={exc.status_code}")
88+
else: # pragma: no cover
89+
raise SystemExit("expected FinOpsNotFoundError")
90+
91+
# ---- metadata.list_public_enumerations small page ----
92+
print("\n[6] metadata.list_public_enumerations(top=3)")
93+
for r in client.metadata.list_public_enumerations(top=3):
94+
print(f" -> {r.get('Name')}")
95+
96+
print("\n" + "=" * 72)
97+
print(" STEP 2 SMOKETEST OK")
98+
print("=" * 72)
99+
return 0
100+
101+
102+
if __name__ == "__main__":
103+
sys.exit(main())

0 commit comments

Comments
 (0)