diff --git a/scratch_finops_smoketest.py b/scratch_finops_smoketest.py new file mode 100644 index 00000000..d50e2227 --- /dev/null +++ b/scratch_finops_smoketest.py @@ -0,0 +1,126 @@ +"""FinOps SDK smoke test — exercises the four CRUD operations against a real +Dynamics 365 Finance & Operations environment. + +This script is a manual harness (not part of pytest). It is the FinOps analogue +of ``scratch_smoketest.py`` for the Dataverse SDK. + +USAGE +----- +1. ``az login --tenant `` (must have FinOps system access) +2. Set the environment variables below. +3. ``python scratch_finops_smoketest.py`` + +ENV VARS +-------- +* FNO_ENV_URL — e.g. https://my-env.operations.int.dynamics.com +* FNO_TENANT_ID — Entra tenant GUID + +SAFE-DEFAULT STRATEGY +--------------------- +Empty/sandbox FinOps envs typically reject CREATE on master entities like +CustomersV3 / CustomerGroups because they have unsatisfied mandatory references +(Currency, payment terms, tax groups, ...). To prove all four CRUD verbs +without depending on env-specific reference data, this script: + + 1. READs the singleton row from the ``LegalEntities`` entity set (always present). + 2. PATCHes a benign string field (``Name``) to a marker value. + 3. READs back to confirm the round-trip. + 4. PATCHes back to the original value. + 5. Attempts a CREATE + DELETE on ``CustomerGroups`` (best-effort; surfaces any + env-validation errors). + +Steps 1-4 prove GET + UPDATE end-to-end. Step 5 proves CREATE + DELETE wire +format (success or controlled failure with structured error body). +""" +from __future__ import annotations + +import os +import sys + +from azure.identity import AzureCliCredential + +from PowerPlatform.FinOps import FinOpsClient, FinOpsHttpError + + +ENV_URL = os.environ.get("FNO_ENV_URL", "https://.operations.int.dynamics.com") +TENANT = os.environ.get("FNO_TENANT_ID", "") + + +def main() -> int: + if "" in ENV_URL or not TENANT: + print("Set FNO_ENV_URL and FNO_TENANT_ID environment variables first.", + file=sys.stderr) + print(" $env:FNO_ENV_URL = 'https://my-env.operations.int.dynamics.com'", file=sys.stderr) + print(" $env:FNO_TENANT_ID = ''", file=sys.stderr) + return 2 + + cred = AzureCliCredential(tenant_id=TENANT, process_timeout=120) + + with FinOpsClient(ENV_URL, cred) as client: + print(f"== Connected to {client.environment_url} ==") + + # ------- 1. service document sanity ------------------------------ + sd = client._http.request("GET", client.data_url, expected=(200,)) + sets = [v["name"] for v in sd.json().get("value", [])] + print(f"Service document OK — {len(sets)} entity sets exposed") + + # ------- 2. GET + UPDATE round-trip on LegalEntities ------------- + leg = client._http.request( + "GET", f"{client.data_url}/LegalEntities", + params={"$top": "1"}, expected=(200,), + ).json()["value"] + if not leg: + print("LegalEntities is empty — cannot run UPDATE round-trip.", file=sys.stderr) + return 3 + leid = leg[0]["LegalEntityId"] + orig = leg[0].get("Name") or "" + print(f"LegalEntity: {leid!r} Name={orig!r}") + + row = client.records.get("LegalEntities", leid) + print(f" records.get -> {len(row)} columns") + + marker = "FinOps-SDK-Step1-smoketest" + client.records.update("LegalEntities", leid, {"Name": marker}) + after = client.records.get("LegalEntities", leid) + assert after.get("Name") == marker, ( + f"UPDATE round-trip failed: expected {marker!r}, got {after.get('Name')!r}" + ) + print(f" records.update + records.get round-trip verified ({marker!r})") + + client.records.update("LegalEntities", leid, {"Name": orig}) + restored = client.records.get("LegalEntities", leid) + assert restored.get("Name") == orig, "Failed to restore original Name" + print(f" Restored Name to {orig!r}") + + # ------- 3. CREATE + DELETE on CustomerGroups (best-effort) ------ + key = {"dataAreaId": "usmf", "CustomerGroupId": "SDK01"} + try: + client.records.delete("CustomerGroups", key) + print("Pre-cleanup CustomerGroups SDK01 -> deleted") + except FinOpsHttpError as e: + print(f"Pre-cleanup CustomerGroups SDK01 -> {e.status_code} (skipped)") + + try: + loc = client.records.create("CustomerGroups", { + "dataAreaId": "usmf", + "CustomerGroupId": "SDK01", + "Description": "FinOps SDK smoke test", + }) + print("CREATE -> ", loc) + except FinOpsHttpError as e: + print(f"CREATE rejected by env validation ({e.status_code}); body excerpt:") + print(" ", str(e.response_body)[:300]) + + try: + client.records.delete("CustomerGroups", key) + print("DELETE -> ok") + except FinOpsHttpError as e: + print(f"DELETE -> {e.status_code} (row not present in this env)") + + print("== FinOps CRUD smoke test PASSED ==") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/src/PowerPlatform/FinOps/__init__.py b/src/PowerPlatform/FinOps/__init__.py new file mode 100644 index 00000000..62bb22d5 --- /dev/null +++ b/src/PowerPlatform/FinOps/__init__.py @@ -0,0 +1,25 @@ +"""PowerPlatform.FinOps — Python SDK for Microsoft Dynamics 365 Finance & Operations. + +Step 1 (this branch) covers the four CRUD operations against the FinOps OData +endpoint (``/data/{EntitySet}``). See ``FinOps-SDK-Plan.docx`` for the full roadmap. +""" +from .client import FinOpsClient +from .errors import ( + FinOpsError, + FinOpsAuthError, + FinOpsHttpError, + FinOpsNotFoundError, + FinOpsConcurrencyError, + FinOpsThrottledError, +) + +__all__ = [ + "FinOpsClient", + "FinOpsError", + "FinOpsAuthError", + "FinOpsHttpError", + "FinOpsNotFoundError", + "FinOpsConcurrencyError", + "FinOpsThrottledError", +] +__version__ = "0.0.1.dev0" diff --git a/src/PowerPlatform/FinOps/_auth.py b/src/PowerPlatform/FinOps/_auth.py new file mode 100644 index 00000000..1199f641 --- /dev/null +++ b/src/PowerPlatform/FinOps/_auth.py @@ -0,0 +1,70 @@ +"""Token acquisition + caching for the FinOps SDK. + +Wraps any ``azure.core.credentials.TokenCredential`` (e.g. the credentials in +``azure-identity``). Caches the bearer token in memory and proactively refreshes +it shortly before expiry. + +Per Platform/AX.Owin/FinOpsAuthenticationOptionsProvider.cs, FinOps tokens are +short-lived and the recommended client cadence is to refresh every ~5 minutes; +we conservatively refresh when fewer than ``REFRESH_SKEW_SECONDS`` remain. +""" +from __future__ import annotations + +import threading +import time +from typing import TYPE_CHECKING, Optional + +from .errors import FinOpsAuthError + +if TYPE_CHECKING: # pragma: no cover + from azure.core.credentials import AccessToken, TokenCredential + + +# Refresh the cached token when this many seconds (or fewer) remain on it. +REFRESH_SKEW_SECONDS = 300 # 5 min + + +class TokenProvider: + """Thread-safe access-token cache for a single FinOps environment.""" + + def __init__(self, credential: "TokenCredential", scope: str) -> None: + if not scope: + raise ValueError("scope must be a non-empty string") + self._credential = credential + self._scope = scope + self._lock = threading.Lock() + self._token: Optional["AccessToken"] = None + + @property + def scope(self) -> str: + return self._scope + + def get_bearer(self) -> str: + """Return a valid bearer token, refreshing in-place if needed.""" + token = self._token + if token is None or self._needs_refresh(token): + with self._lock: + token = self._token + if token is None or self._needs_refresh(token): + token = self._acquire() + self._token = token + return token.token + + def invalidate(self) -> None: + """Drop the cached token (forces a fresh acquisition next call).""" + with self._lock: + self._token = None + + # -- internals ------------------------------------------------------- + + @staticmethod + def _needs_refresh(token: "AccessToken") -> bool: + return token.expires_on - time.time() <= REFRESH_SKEW_SECONDS + + def _acquire(self) -> "AccessToken": + try: + return self._credential.get_token(self._scope) + except Exception as exc: # pragma: no cover - re-raised + raise FinOpsAuthError( + f"Failed to acquire token for scope {self._scope!r}: {exc}" + ) from exc diff --git a/src/PowerPlatform/FinOps/_http.py b/src/PowerPlatform/FinOps/_http.py new file mode 100644 index 00000000..b336d5ac --- /dev/null +++ b/src/PowerPlatform/FinOps/_http.py @@ -0,0 +1,193 @@ +"""HTTP transport for the FinOps SDK. + +Single ``requests.Session`` per client, with: + + * Bearer token injection via the ``TokenProvider`` cache. + * Bounded retry on transient failures (429 / 502 / 503 / 504 / 408) + using exponential backoff and honoring ``Retry-After`` headers. + * Mapping of non-2xx responses to the SDK exception hierarchy. + +The retry shape intentionally mirrors the guidance captured in the +FinOps Platform memory bank (apiReference.md: transient SQL retry codes, +priority-based throttling, 5-minute token refresh). +""" +from __future__ import annotations + +import logging +import random +import time +from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Tuple + +import requests + +from .errors import ( + FinOpsConcurrencyError, + FinOpsHttpError, + FinOpsNotFoundError, + FinOpsThrottledError, +) + +if TYPE_CHECKING: # pragma: no cover + from ._auth import TokenProvider + + +logger = logging.getLogger("PowerPlatform.FinOps") + +# Status codes that trigger a retry. +_RETRY_STATUS = frozenset({408, 429, 502, 503, 504}) + +# Headers FinOps uses to surface activity-id (for support correlation). +_ACTIVITY_HEADERS = ("ms-dyn-activityid", "request-id", "x-ms-request-id") + + +class HttpClient: + """Thin retrying wrapper around ``requests.Session``.""" + + def __init__( + self, + token_provider: "TokenProvider", + *, + max_retries: int = 5, + backoff_initial: float = 0.5, + backoff_cap: float = 30.0, + timeout: float = 60.0, + user_agent: str = "PowerPlatform-FinOps-Python/0.0.1", + ) -> None: + self._tp = token_provider + self._max_retries = max_retries + self._backoff_initial = backoff_initial + self._backoff_cap = backoff_cap + self._timeout = timeout + self._session = requests.Session() + self._session.headers.update( + { + "Accept": "application/json", + "OData-Version": "4.0", + "OData-MaxVersion": "4.0", + "User-Agent": user_agent, + } + ) + + def close(self) -> None: + self._session.close() + + # -- public ----------------------------------------------------------- + + def request( + self, + method: str, + url: str, + *, + params: Optional[Mapping[str, Any]] = None, + json: Any = None, + headers: Optional[Mapping[str, str]] = None, + expected: Tuple[int, ...] = (200, 201, 204), + ) -> requests.Response: + """Issue an HTTP call with auth + retries; raise on unexpected status.""" + attempt = 0 + while True: + attempt += 1 + merged = self._build_headers(headers) + try: + resp = self._session.request( + method=method, + url=url, + params=params, + json=json, + headers=merged, + timeout=self._timeout, + ) + except requests.RequestException as exc: + if attempt > self._max_retries: + raise FinOpsHttpError( + f"Network error after {attempt - 1} retries: {exc}", + status_code=0, + url=url, + ) from exc + self._sleep_backoff(attempt, retry_after=None) + continue + + if resp.status_code in expected: + return resp + + if resp.status_code == 401: + # Token may have expired between cache check and the wire; + # invalidate and retry once. + if attempt == 1: + self._tp.invalidate() + continue + self._raise(resp) + + if resp.status_code in _RETRY_STATUS and attempt <= self._max_retries: + self._sleep_backoff(attempt, retry_after=self._retry_after(resp)) + continue + + self._raise(resp) + + # -- internals -------------------------------------------------------- + + def _build_headers( + self, extra: Optional[Mapping[str, str]] + ) -> Dict[str, str]: + headers = {"Authorization": f"Bearer {self._tp.get_bearer()}"} + if extra: + headers.update(extra) + return headers + + def _sleep_backoff(self, attempt: int, *, retry_after: Optional[float]) -> None: + if retry_after is not None: + delay = retry_after + else: + # Exponential backoff with full jitter. + delay = min(self._backoff_cap, self._backoff_initial * (2 ** (attempt - 1))) + delay = random.uniform(0, delay) + logger.debug("FinOps SDK retry attempt=%d sleeping=%.2fs", attempt, delay) + time.sleep(delay) + + @staticmethod + def _retry_after(resp: requests.Response) -> Optional[float]: + ra = resp.headers.get("Retry-After") + if not ra: + return None + try: + return max(0.0, float(ra)) + except ValueError: + return None + + @classmethod + def _activity_id(cls, resp: requests.Response) -> Optional[str]: + for h in _ACTIVITY_HEADERS: + v = resp.headers.get(h) + if v: + return v + return None + + @classmethod + def _raise(cls, resp: requests.Response) -> None: + try: + body: Any = resp.json() + except ValueError: + body = resp.text + activity = cls._activity_id(resp) + msg = f"FinOps {resp.request.method} {resp.url} failed: {resp.status_code}" + sc = resp.status_code + if sc == 404: + raise FinOpsNotFoundError( + msg, status_code=sc, activity_id=activity, response_body=body, url=resp.url + ) + if sc == 412: + raise FinOpsConcurrencyError( + msg, status_code=sc, activity_id=activity, response_body=body, url=resp.url + ) + if sc == 429: + raise FinOpsThrottledError( + msg, + status_code=sc, + activity_id=activity, + response_body=body, + url=resp.url, + retry_after=cls._retry_after(resp), + ) + raise FinOpsHttpError( + msg, status_code=sc, activity_id=activity, response_body=body, url=resp.url + ) diff --git a/src/PowerPlatform/FinOps/client.py b/src/PowerPlatform/FinOps/client.py new file mode 100644 index 00000000..de8d91d1 --- /dev/null +++ b/src/PowerPlatform/FinOps/client.py @@ -0,0 +1,98 @@ +"""Public client entry point for the FinOps SDK.""" +from __future__ import annotations + +from types import TracebackType +from typing import TYPE_CHECKING, Optional, Type + +from ._auth import TokenProvider +from ._http import HttpClient +from .operations import MetadataOperations, RecordOperations + +if TYPE_CHECKING: # pragma: no cover + from azure.core.credentials import TokenCredential + + +class FinOpsClient: + """High-level client for a single Dynamics 365 Finance & Operations environment. + + Parameters + ---------- + environment_url: + Base URL of the FinOps environment, e.g. + ``"https://my-finops-env.cloudax.dynamics.com"``. Trailing slashes are + tolerated. + credential: + Any object satisfying ``azure.core.credentials.TokenCredential`` — + ``azure.identity.AzureCliCredential``, ``DefaultAzureCredential``, + ``ClientSecretCredential``, etc. + scope: + Optional override for the OAuth scope. Defaults to + ``"/.default"`` (the standard Entra resource-scope form). + + Example + ------- + >>> from azure.identity import AzureCliCredential + >>> from PowerPlatform.FinOps import FinOpsClient + >>> with FinOpsClient(env_url, AzureCliCredential()) as client: + ... loc = client.records.create("CustomersV3", {"CustomerAccount": "TEST", ...}) + ... row = client.records.get("CustomersV3", + ... {"dataAreaId": "usmf", "CustomerAccount": "TEST"}) + ... client.records.update("CustomersV3", + ... {"dataAreaId": "usmf", "CustomerAccount": "TEST"}, + ... {"OrganizationName": "Updated"}) + ... client.records.delete("CustomersV3", + ... {"dataAreaId": "usmf", "CustomerAccount": "TEST"}) + """ + + def __init__( + self, + environment_url: str, + credential: "TokenCredential", + *, + scope: Optional[str] = None, + max_retries: int = 5, + timeout: float = 60.0, + ) -> None: + if not environment_url: + raise ValueError("environment_url is required") + self._env_url = environment_url.rstrip("/") + self._data_url = f"{self._env_url}/data" + self._scope = scope or f"{self._env_url}/.default" + self._token_provider = TokenProvider(credential, self._scope) + self._http = HttpClient( + self._token_provider, max_retries=max_retries, timeout=timeout + ) + + # Operation namespaces. + self.records = RecordOperations(self) + self.metadata = MetadataOperations(self) + + # -- accessors ------------------------------------------------------ + + @property + def environment_url(self) -> str: + return self._env_url + + @property + def data_url(self) -> str: + return self._data_url + + @property + def scope(self) -> str: + return self._scope + + # -- lifecycle ------------------------------------------------------ + + def close(self) -> None: + self._http.close() + + def __enter__(self) -> "FinOpsClient": + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + self.close() diff --git a/src/PowerPlatform/FinOps/errors.py b/src/PowerPlatform/FinOps/errors.py new file mode 100644 index 00000000..3e688abf --- /dev/null +++ b/src/PowerPlatform/FinOps/errors.py @@ -0,0 +1,64 @@ +"""Exception hierarchy for the FinOps SDK. + +Mirrors the shape of the Dataverse SDK's structured-error model so callers +that already use the Dataverse client get a familiar surface. +""" +from __future__ import annotations + +from typing import Any, Optional + + +class FinOpsError(Exception): + """Base class for all FinOps SDK errors.""" + + +class FinOpsAuthError(FinOpsError): + """Raised when token acquisition or refresh fails.""" + + +class FinOpsHttpError(FinOpsError): + """Raised for any non-2xx HTTP response from the FinOps server. + + Carries the activity-id surfaced by FinOps for support correlation, + when present (header ``ms-dyn-activityid`` or ``request-id``). + """ + + def __init__( + self, + message: str, + *, + status_code: int, + activity_id: Optional[str] = None, + response_body: Any = None, + url: Optional[str] = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.activity_id = activity_id + self.response_body = response_body + self.url = url + + def __str__(self) -> str: # pragma: no cover - cosmetic + base = super().__str__() + bits = [f"status={self.status_code}"] + if self.activity_id: + bits.append(f"activity_id={self.activity_id}") + if self.url: + bits.append(f"url={self.url}") + return f"{base} ({', '.join(bits)})" + + +class FinOpsNotFoundError(FinOpsHttpError): + """HTTP 404.""" + + +class FinOpsConcurrencyError(FinOpsHttpError): + """HTTP 412 — precondition failed (ETag mismatch on update/delete).""" + + +class FinOpsThrottledError(FinOpsHttpError): + """HTTP 429 after retries exhausted.""" + + def __init__(self, *args: Any, retry_after: Optional[float] = None, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.retry_after = retry_after diff --git a/src/PowerPlatform/FinOps/operations/__init__.py b/src/PowerPlatform/FinOps/operations/__init__.py new file mode 100644 index 00000000..233b5eb2 --- /dev/null +++ b/src/PowerPlatform/FinOps/operations/__init__.py @@ -0,0 +1,5 @@ +"""Operations namespaces for the FinOps SDK.""" +from .metadata import MetadataOperations +from .records import RecordOperations + +__all__ = ["MetadataOperations", "RecordOperations"] diff --git a/src/PowerPlatform/FinOps/operations/metadata.py b/src/PowerPlatform/FinOps/operations/metadata.py new file mode 100644 index 00000000..28f0474b --- /dev/null +++ b/src/PowerPlatform/FinOps/operations/metadata.py @@ -0,0 +1,158 @@ +"""Metadata read operations for the FinOps SDK. + +This is **Step 2** of the FinOps SDK roadmap captured in +``FinOps-SDK-Plan.docx``. It wraps the read-only metadata surface exposed +by the FinOps Platform under ``/metadata/...``: + +============================== ============================================== +HTTP SDK call +============================== ============================================== +``GET /metadata/DataEntities`` ``client.metadata.list_data_entities(...)`` +``GET /metadata/DataEntities('N')`` ``client.metadata.get_data_entity('N')`` +``GET /metadata/PublicEntities`` ``client.metadata.list_public_entities(...)`` +``GET /metadata/PublicEntities('N')`` ``client.metadata.get_public_entity('N')`` +``GET /metadata/PublicEnumerations`` ``client.metadata.list_public_enumerations()`` +============================== ============================================== + +These verbs are backed by ``DataEntitiesController`` and sibling controllers in +the FinOps Platform under +``Source/Platform/Integration/Services/WebApi/Metadata/Source/Controllers/``. + +These endpoints are read-only by design — there is no runtime metadata-write +API in FinOps (schema is authored in X++ and built into the model layer; see +``FinOps-SDK-Plan.docx`` §7). +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterator, Optional +from urllib.parse import quote + +from ..errors import FinOpsError + +if TYPE_CHECKING: # pragma: no cover + from ..client import FinOpsClient + + +class MetadataOperations: + """Read-only metadata operations on the FinOps ``/metadata`` surface. + + Obtain via ``FinOpsClient.metadata`` — do not instantiate directly. + """ + + def __init__(self, client: "FinOpsClient") -> None: + self._client = client + + # ------------------------------------------------------------------ # + # /metadata/DataEntities # + # ------------------------------------------------------------------ # + def list_data_entities( + self, + *, + filter: Optional[str] = None, + top: Optional[int] = None, + ) -> Iterator[dict]: + """``GET /metadata/DataEntities`` — yield every public data entity descriptor. + + Returns one row at a time, transparently following the + ``@odata.nextLink`` continuation token. + + .. note:: + The metadata controllers do **not** support ``$select`` (the server + replies HTTP 400). ``$top`` is accepted but currently ignored + server-side, so this SDK enforces ``top`` as a client-side cap. + """ + yield from self._paginate("DataEntities", filter=filter, top=top) + + def get_data_entity(self, name: str) -> dict: + """``GET /metadata/DataEntities('Name')`` — single entity descriptor.""" + if not name: + raise ValueError("entity name is required") + url = f"{self._metadata_url()}/DataEntities('{_escape(name)}')" + return self._client._http.request("GET", url, expected=(200,)).json() + + # ------------------------------------------------------------------ # + # /metadata/PublicEntities # + # ------------------------------------------------------------------ # + def list_public_entities( + self, + *, + filter: Optional[str] = None, + top: Optional[int] = None, + ) -> Iterator[dict]: + """``GET /metadata/PublicEntities`` — yield every public entity (with column metadata). + + See :meth:`list_data_entities` for ``$select``/``$top`` caveats. + """ + yield from self._paginate("PublicEntities", filter=filter, top=top) + + def get_public_entity(self, name: str) -> dict: + """``GET /metadata/PublicEntities('Name')`` — single entity with column metadata.""" + if not name: + raise ValueError("entity name is required") + url = f"{self._metadata_url()}/PublicEntities('{_escape(name)}')" + return self._client._http.request("GET", url, expected=(200,)).json() + + # ------------------------------------------------------------------ # + # /metadata/PublicEnumerations # + # ------------------------------------------------------------------ # + def list_public_enumerations( + self, + *, + filter: Optional[str] = None, + top: Optional[int] = None, + ) -> Iterator[dict]: + """``GET /metadata/PublicEnumerations`` — yield every public enum descriptor.""" + yield from self._paginate("PublicEnumerations", filter=filter, top=top) + + def get_public_enumeration(self, name: str) -> dict: + """``GET /metadata/PublicEnumerations('Name')`` — single enum descriptor.""" + if not name: + raise ValueError("enumeration name is required") + url = f"{self._metadata_url()}/PublicEnumerations('{_escape(name)}')" + return self._client._http.request("GET", url, expected=(200,)).json() + + # ------------------------------------------------------------------ # + # internals # + # ------------------------------------------------------------------ # + def _metadata_url(self) -> str: + return f"{self._client.environment_url}/metadata" + + def _paginate( + self, + collection: str, + *, + filter: Optional[str] = None, + top: Optional[int] = None, + ) -> Iterator[dict]: + params: dict = {} + if filter: + params["$filter"] = filter + if top is not None: + if top <= 0: + return + # Server currently ignores $top on /metadata/* but sending it is + # harmless and lets us upgrade transparently if/when it lands. + params["$top"] = str(top) + + url: Optional[str] = f"{self._metadata_url()}/{collection}" + request_params: Optional[dict] = params or None + yielded = 0 + while url: + resp = self._client._http.request( + "GET", url, params=request_params, expected=(200,) + ) + payload = resp.json() + for row in payload.get("value", []): + if top is not None and yielded >= top: + return + yield row + yielded += 1 + url = payload.get("@odata.nextLink") + request_params = None + + +def _escape(name: str) -> str: + if "'" in name: + # OData v4 string literal escape: single quotes are doubled. + return name.replace("'", "''") + return name diff --git a/src/PowerPlatform/FinOps/operations/records.py b/src/PowerPlatform/FinOps/operations/records.py new file mode 100644 index 00000000..674880a1 --- /dev/null +++ b/src/PowerPlatform/FinOps/operations/records.py @@ -0,0 +1,317 @@ +"""CRUD operations for FinOps OData entities (``/data/{EntitySet}``). + +This is **Step 1** of the FinOps SDK roadmap captured in +``FinOps-SDK-Plan.docx``. It implements only the four basic CRUD verbs: + +============ ========================================= ===================================== +Verb HTTP SDK call +============ ========================================= ===================================== +Create ``POST /data/{EntitySet}`` ``client.records.create(...)`` +Retrieve ``GET /data/{EntitySet}({key})`` ``client.records.get(...)`` +Update ``PATCH /data/{EntitySet}({key})`` ``client.records.update(...)`` +Delete ``DELETE /data/{EntitySet}({key})`` ``client.records.delete(...)`` +============ ========================================= ===================================== + +These verbs are backed by ``AxODataController`` in the FinOps Platform +(``Source/Platform/Integration/Services/OData/Sources/AxODataController.cs``), +which exposes standard OData v4 over the ``/data`` path. + +Notes +----- +* **Composite keys.** Many FinOps entities are keyed by the company partition + (``dataAreaId``) plus one or more business identifiers. This module accepts + either a scalar ``key`` (single-valued) or a ``Mapping[str, Any]`` (composite), + and serializes it into the canonical OData key syntax + ``Set(name1=value1,name2=value2)``. +* **Optimistic concurrency.** Update / Delete accept an optional ``etag``; + it's emitted as ``If-Match``. Defaults to ``*`` (overwrite) so the basic + CRUD path 'just works' for the v0.1 spike. +* The Create call returns the URL of the newly created entity (extracted from + the ``OData-EntityId`` header) when the server returns a Location header. + When it does not, the deserialized response body is returned instead. +""" +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal +from typing import TYPE_CHECKING, Any, Iterator, Mapping, Optional, Sequence, Union +from urllib.parse import quote + +from ..errors import FinOpsError + +if TYPE_CHECKING: # pragma: no cover + from ..client import FinOpsClient + + +KeyType = Union[str, int, Mapping[str, Any]] + + +class RecordOperations: + """CRUD operations on FinOps OData entity sets. + + Obtain via ``FinOpsClient.records`` — do not instantiate directly. + """ + + def __init__(self, client: "FinOpsClient") -> None: + self._client = client + + # ------------------------------------------------------------------ # + # CREATE # + # ------------------------------------------------------------------ # + def create(self, entity_set: str, data: Mapping[str, Any]) -> Union[str, dict]: + """``POST /data/{entity_set}`` — create one record. + + Returns + ------- + str + The fully-qualified URL of the newly-created entity, taken + from the ``OData-EntityId`` response header, when present. + dict + Otherwise, the deserialized JSON response body. + """ + if not isinstance(data, Mapping): + raise TypeError("data must be a mapping of column -> value") + url = self._collection_url(entity_set) + resp = self._client._http.request( + "POST", + url, + json=dict(data), + headers={"Content-Type": "application/json"}, + expected=(200, 201, 204), + ) + # FinOps (like Dataverse) returns the new entity's URL in OData-EntityId. + loc = resp.headers.get("OData-EntityId") or resp.headers.get("Location") + if loc: + return loc + if resp.content: + return resp.json() + return {} + + # ------------------------------------------------------------------ # + # RETRIEVE # + # ------------------------------------------------------------------ # + def get( + self, + entity_set: str, + key: KeyType, + *, + select: Optional[list] = None, + expand: Optional[list] = None, + ) -> dict: + """``GET /data/{entity_set}({key})`` — read one record. + + Parameters + ---------- + entity_set: + FinOps OData entity set name (e.g. ``"CustomersV3"``). + key: + Single-value key or a mapping for composite keys. + select: + Optional list of column names for the OData ``$select`` projection. + expand: + Optional list of navigation properties for ``$expand``. + """ + url = self._record_url(entity_set, key) + params: dict = {} + if select: + params["$select"] = ",".join(select) + if expand: + params["$expand"] = ",".join(expand) + resp = self._client._http.request("GET", url, params=params or None, expected=(200,)) + return resp.json() + + # ------------------------------------------------------------------ # + # LIST (paginated) # + # ------------------------------------------------------------------ # + def list( + self, + entity_set: str, + *, + filter: Optional[str] = None, + select: Optional[Sequence[str]] = None, + expand: Optional[Sequence[str]] = None, + orderby: Optional[Union[str, Sequence[str]]] = None, + top: Optional[int] = None, + page_size: Optional[int] = None, + cross_company: bool = False, + ) -> Iterator[dict]: + """``GET /data/{entity_set}`` — yield rows lazily across all pages. + + Transparently follows the ``@odata.nextLink`` continuation token + emitted by FinOps OData and yields one row dict at a time. Stops + after ``top`` rows when given (server is told via ``$top``; client + also caps just in case the server ignores it). + + Parameters + ---------- + entity_set: + FinOps OData entity set name (e.g. ``"CustomersV3"``). + filter: + Raw OData ``$filter`` expression. Callers are responsible for + quoting; the SDK will not try to parse it. A typed query builder + is on the roadmap (see ``FinOps-SDK-Plan.docx`` §8 Phase 2). + select: + Column names for ``$select``. + expand: + Navigation properties for ``$expand``. + orderby: + Either a single OData ordering clause (``"CreatedDateTime desc"``) + or a list of them. + top: + Hard cap on rows. Sent server-side as ``$top`` and enforced + client-side as a defensive stop. + page_size: + Optional ``Prefer: odata.maxpagesize=N`` hint to ask the server + for smaller pages — useful when the dataset is large and the + caller is paging memory-sensitively. + cross_company: + When ``True``, sends the FinOps-specific ``cross-company=true`` + query parameter so rows from every legal entity (``dataAreaId``) + are returned. Default is ``False``, which mirrors the FinOps + OData default of scoping to the caller's default company. + + Yields + ------ + dict + One OData row at a time. + """ + params: dict = {} + if cross_company: + params["cross-company"] = "true" + if filter: + params["$filter"] = filter + if select: + params["$select"] = ",".join(select) + if expand: + params["$expand"] = ",".join(expand) + if orderby: + params["$orderby"] = orderby if isinstance(orderby, str) else ",".join(orderby) + if top is not None: + if top <= 0: + return + params["$top"] = str(top) + + headers: Optional[dict] = None + if page_size is not None: + if page_size <= 0: + raise ValueError("page_size must be positive") + headers = {"Prefer": f"odata.maxpagesize={int(page_size)}"} + + url: Optional[str] = self._collection_url(entity_set) + request_params: Optional[dict] = params or None + yielded = 0 + while url: + resp = self._client._http.request( + "GET", url, params=request_params, headers=headers, expected=(200,) + ) + payload = resp.json() + for row in payload.get("value", []): + if top is not None and yielded >= top: + return + yield row + yielded += 1 + url = payload.get("@odata.nextLink") + # The nextLink already encodes all of the original $-options. + request_params = None + + + def update( + self, + entity_set: str, + key: KeyType, + changes: Mapping[str, Any], + *, + etag: str = "*", + ) -> None: + """``PATCH /data/{entity_set}({key})`` — partial update. + + ``etag`` is emitted as the ``If-Match`` header (defaults to ``*`` — + unconditional overwrite). Pass a real ETag string to enable optimistic + concurrency; the call will raise :class:`FinOpsConcurrencyError` (412) + on mismatch. + """ + if not isinstance(changes, Mapping): + raise TypeError("changes must be a mapping of column -> value") + if not changes: + raise ValueError("changes is empty — nothing to update") + url = self._record_url(entity_set, key) + self._client._http.request( + "PATCH", + url, + json=dict(changes), + headers={"Content-Type": "application/json", "If-Match": etag}, + expected=(200, 204), + ) + + # ------------------------------------------------------------------ # + # DELETE # + # ------------------------------------------------------------------ # + def delete( + self, + entity_set: str, + key: KeyType, + *, + etag: str = "*", + ) -> None: + """``DELETE /data/{entity_set}({key})`` — delete one record. + + ``etag`` is emitted as ``If-Match`` (defaults to ``*``). + """ + url = self._record_url(entity_set, key) + self._client._http.request( + "DELETE", + url, + headers={"If-Match": etag}, + expected=(200, 204), + ) + + # ------------------------------------------------------------------ # + # URL helpers # + # ------------------------------------------------------------------ # + def _collection_url(self, entity_set: str) -> str: + return f"{self._client._data_url}/{_safe_segment(entity_set)}" + + def _record_url(self, entity_set: str, key: KeyType) -> str: + return f"{self._collection_url(entity_set)}({_format_key(key)})" + + +# ---------------------------------------------------------------------- # +# OData key formatting # +# ---------------------------------------------------------------------- # + + +def _safe_segment(name: str) -> str: + if not name or "/" in name: + raise FinOpsError(f"invalid entity set name: {name!r}") + return quote(name, safe="") + + +def _format_key(key: KeyType) -> str: + """Render ``key`` in canonical OData key syntax.""" + if isinstance(key, Mapping): + if not key: + raise ValueError("composite key mapping cannot be empty") + parts = [f"{_safe_segment(k)}={_format_value(v)}" for k, v in key.items()] + return ",".join(parts) + return _format_value(key) + + +def _format_value(v: Any) -> str: + """OData v4 primitive literal serialization (subset sufficient for keys).""" + if v is None: + return "null" + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, (int, Decimal)): + return str(v) + if isinstance(v, float): + return repr(v) + if isinstance(v, datetime): + return v.isoformat() + if isinstance(v, date): + return v.isoformat() + if isinstance(v, str): + # Single quotes inside values are doubled in OData literals. + escaped = v.replace("'", "''") + return f"'{escaped}'" + raise TypeError(f"unsupported OData key value type: {type(v).__name__}") diff --git a/step2_smoketest.py b/step2_smoketest.py new file mode 100644 index 00000000..671a0cc0 --- /dev/null +++ b/step2_smoketest.py @@ -0,0 +1,103 @@ +"""Step 2 live smoketest — list pagination + metadata reads. + +Run from the C:\\finops-crud-demo .venv with the SDK installed editable. +Requires: az login (see README), then `python step2_smoketest.py`. +""" +from __future__ import annotations + +import os +import sys + +from azure.identity import AzureCliCredential + +from PowerPlatform.FinOps import FinOpsClient + + +def main() -> int: + env_url = os.environ.get( + "FNO_ENV_URL", "https://aurorabapenvdc9e7.operations.int.dynamics.com" + ) + tenant_id = os.environ.get( + "FNO_TENANT_ID", "4abc24ea-2d0b-4011-87d4-3de32ca1e9cc" + ) + + print("=" * 72) + print(f" Step 2 smoketest against {env_url}") + print("=" * 72) + + cred = AzureCliCredential(tenant_id=tenant_id) + with FinOpsClient(env_url, cred) as client: + + # ---- records.list with $top + $select ---- + print("\n[1] records.list(LegalEntities, top=3, select=[LegalEntityId, Name])") + rows = list( + client.records.list( + "LegalEntities", + select=["LegalEntityId", "Name"], + top=3, + ) + ) + for r in rows: + print(f" -> {r.get('LegalEntityId')!r} {r.get('Name')!r}") + print(f" => yielded {len(rows)} rows (top=3)") + + # ---- records.list paginating across many CustomerGroups ---- + print("\n[2] records.list(CustomerGroups, cross_company=True, page_size=10) iterate first 25") + cg_iter = client.records.list( + "CustomerGroups", cross_company=True, page_size=10 + ) + first25 = [] + for row in cg_iter: + first25.append(row.get("CustomerGroupId")) + if len(first25) >= 25: + break + print(f" -> first IDs: {first25[:10]} ...") + print(f" => collected {len(first25)} rows across pages of 10") + + # ---- metadata.list_data_entities ---- + print("\n[3] metadata.list_data_entities(top=5) (server ignores $top, SDK caps client-side)") + de_rows = list(client.metadata.list_data_entities(top=5)) + for r in de_rows: + print(f" -> {r.get('Name')} ({r.get('PublicEntityName')})") + print(f" => yielded {len(de_rows)} rows") + + # ---- metadata.get_data_entity for one we know exists ---- + # `Name` is the X++ entity name (e.g. `AbbreviationsEntity`), not the + # public OData entity set name. We pick whatever name came back from + # phase 3 above so the smoketest is self-bootstrapping. + if not de_rows: + print("\n[4] SKIPPED — phase 3 yielded no rows") + else: + target_name = de_rows[0]["Name"] + print(f"\n[4] metadata.get_data_entity({target_name!r})") + de = client.metadata.get_data_entity(target_name) + print( + f" -> Name={de.get('Name')!r} " + f"PublicEntityName={de.get('PublicEntityName')!r} " + f"IsReadOnly={de.get('IsReadOnly')}" + ) + + # ---- typed 404 mapping for missing metadata name ---- + from PowerPlatform.FinOps.errors import FinOpsNotFoundError + + print("\n[5] metadata.get_data_entity('NoSuchEntityXYZ') (expecting typed 404)") + try: + client.metadata.get_data_entity("NoSuchEntityXYZ") + except FinOpsNotFoundError as exc: + print(f" -> typed FinOpsNotFoundError raised: status={exc.status_code}") + else: # pragma: no cover + raise SystemExit("expected FinOpsNotFoundError") + + # ---- metadata.list_public_enumerations small page ---- + print("\n[6] metadata.list_public_enumerations(top=3)") + for r in client.metadata.list_public_enumerations(top=3): + print(f" -> {r.get('Name')}") + + print("\n" + "=" * 72) + print(" STEP 2 SMOKETEST OK") + print("=" * 72) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/finops/__init__.py b/tests/finops/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/finops/test_metadata.py b/tests/finops/test_metadata.py new file mode 100644 index 00000000..8636737a --- /dev/null +++ b/tests/finops/test_metadata.py @@ -0,0 +1,152 @@ +"""Unit tests for ``MetadataOperations``. + +These tests do not hit a real FinOps environment; they patch +``HttpClient.request`` to assert the SDK builds the right URLs against +the ``/metadata/...`` surface and follows ``@odata.nextLink``. +""" +from __future__ import annotations + +import time +from unittest.mock import MagicMock, patch + +import pytest + +from PowerPlatform.FinOps import FinOpsClient + +ENV_URL = "https://my-finops-env.cloudax.dynamics.com" + + +class _FakeCredential: + def get_token(self, *scopes: str, **_: object): + return MagicMock(token="t", expires_on=int(time.time()) + 3600) + + +def _make_response(json_body): + resp = MagicMock() + resp.status_code = 200 + resp.headers = {} + resp.url = "https://stubbed/" + resp.request = MagicMock(method="GET", url="https://stubbed/") + resp.json = MagicMock(return_value=json_body) + resp.content = b"{}" + resp.text = "" + return resp + + +@pytest.fixture +def client(): + c = FinOpsClient(ENV_URL, _FakeCredential()) + yield c + c.close() + + +class TestListDataEntities: + def test_url_and_no_params_by_default(self, client): + with patch.object( + client._http, "request", return_value=_make_response({"value": []}) + ) as m: + list(client.metadata.list_data_entities()) + args, kwargs = m.call_args + assert args == ("GET", f"{ENV_URL}/metadata/DataEntities") + assert kwargs["params"] is None + + def test_filter_select_top(self, client): + with patch.object( + client._http, "request", return_value=_make_response({"value": []}) + ) as m: + list( + client.metadata.list_data_entities( + filter="IsReadOnly eq false", + top=10, + ) + ) + params = m.call_args.kwargs["params"] + # Server rejects $select on /metadata/* with HTTP 400, so the SDK + # intentionally does not expose it. Only $filter and $top are sent. + assert params == { + "$filter": "IsReadOnly eq false", + "$top": "10", + } + + def test_follows_nextlink(self, client): + next_url = f"{ENV_URL}/metadata/DataEntities?cookie=zzz" + responses = [ + _make_response({"value": [{"Name": "A"}], "@odata.nextLink": next_url}), + _make_response({"value": [{"Name": "B"}]}), + ] + with patch.object(client._http, "request", side_effect=responses) as m: + rows = list(client.metadata.list_data_entities()) + assert [r["Name"] for r in rows] == ["A", "B"] + assert m.call_args_list[1].args[1] == next_url + assert m.call_args_list[1].kwargs["params"] is None + + +class TestGetDataEntity: + def test_url(self, client): + with patch.object( + client._http, "request", return_value=_make_response({"Name": "Customers"}) + ) as m: + row = client.metadata.get_data_entity("Customers") + assert row == {"Name": "Customers"} + assert m.call_args.args == ( + "GET", + f"{ENV_URL}/metadata/DataEntities('Customers')", + ) + + def test_escapes_single_quote(self, client): + with patch.object( + client._http, "request", return_value=_make_response({"x": 1}) + ) as m: + client.metadata.get_data_entity("foo'bar") + # OData v4 string literal escape: '' inside the literal. + assert m.call_args.args[1] == f"{ENV_URL}/metadata/DataEntities('foo''bar')" + + def test_empty_name_raises(self, client): + with pytest.raises(ValueError): + client.metadata.get_data_entity("") + + +class TestPublicEntities: + def test_list_url(self, client): + with patch.object( + client._http, "request", return_value=_make_response({"value": []}) + ) as m: + list(client.metadata.list_public_entities(top=1)) + assert m.call_args.args == ("GET", f"{ENV_URL}/metadata/PublicEntities") + assert m.call_args.kwargs["params"]["$top"] == "1" + + def test_get_url(self, client): + with patch.object( + client._http, "request", return_value=_make_response({}) + ) as m: + client.metadata.get_public_entity("CustomerV3Entity") + assert m.call_args.args == ( + "GET", + f"{ENV_URL}/metadata/PublicEntities('CustomerV3Entity')", + ) + + +class TestPublicEnumerations: + def test_list_url(self, client): + with patch.object( + client._http, "request", return_value=_make_response({"value": []}) + ) as m: + list(client.metadata.list_public_enumerations()) + assert m.call_args.args == ( + "GET", + f"{ENV_URL}/metadata/PublicEnumerations", + ) + + def test_get_url(self, client): + with patch.object( + client._http, "request", return_value=_make_response({}) + ) as m: + client.metadata.get_public_enumeration("NoYes") + assert m.call_args.args == ( + "GET", + f"{ENV_URL}/metadata/PublicEnumerations('NoYes')", + ) + + def test_empty_name_raises(self, client): + with pytest.raises(ValueError): + client.metadata.get_public_enumeration("") diff --git a/tests/finops/test_records.py b/tests/finops/test_records.py new file mode 100644 index 00000000..e4d7d14b --- /dev/null +++ b/tests/finops/test_records.py @@ -0,0 +1,287 @@ +"""Unit tests for ``PowerPlatform.FinOps.operations.records``. + +These tests do not hit a real FinOps environment. They patch the SDK's +``HttpClient.request`` method to assert the SDK builds the right URLs, +headers, and bodies for the four CRUD verbs. +""" +from __future__ import annotations + +import time +from unittest.mock import MagicMock, patch + +import pytest + +from PowerPlatform.FinOps import ( + FinOpsClient, + FinOpsConcurrencyError, + FinOpsHttpError, + FinOpsNotFoundError, +) +from PowerPlatform.FinOps.operations.records import _format_key, _format_value + + +# ---------------------------------------------------------------------- # +# Fixtures # +# ---------------------------------------------------------------------- # + +ENV_URL = "https://my-finops-env.cloudax.dynamics.com" + + +class _FakeCredential: + """Minimal stand-in for ``azure.core.credentials.TokenCredential``.""" + + def __init__(self, token: str = "test-token", ttl: int = 3600) -> None: + self._token = token + self._ttl = ttl + self.calls = 0 + + def get_token(self, *scopes: str, **_: object): + self.calls += 1 + return MagicMock(token=self._token, expires_on=int(time.time()) + self._ttl) + + +def _make_response( + status: int = 200, + *, + json_body: object = None, + headers: dict | None = None, + text: str = "", +): + resp = MagicMock() + resp.status_code = status + resp.headers = headers or {} + resp.url = "https://stubbed/" + resp.request = MagicMock(method="GET", url="https://stubbed/") + if json_body is not None: + resp.json = MagicMock(return_value=json_body) + resp.content = b"{}" + else: + resp.json = MagicMock(side_effect=ValueError()) + resp.content = text.encode() if text else b"" + resp.text = text + return resp + + +@pytest.fixture +def client(): + cred = _FakeCredential() + c = FinOpsClient(ENV_URL, cred) + yield c + c.close() + + +# ---------------------------------------------------------------------- # +# OData key formatting # +# ---------------------------------------------------------------------- # + + +class TestKeyFormatting: + def test_string_key_quoted(self): + assert _format_key("M0001") == "'M0001'" + + def test_int_key_unquoted(self): + assert _format_key(42) == "42" + + def test_string_key_escapes_single_quotes(self): + assert _format_key("O'Brien") == "'O''Brien'" + + def test_composite_key(self): + out = _format_key({"dataAreaId": "usmf", "ItemNumber": "M0001"}) + assert out == "dataAreaId='usmf',ItemNumber='M0001'" + + def test_bool_value(self): + assert _format_value(True) == "true" + assert _format_value(False) == "false" + + def test_empty_composite_rejected(self): + with pytest.raises(ValueError): + _format_key({}) + + def test_unsupported_type_rejected(self): + with pytest.raises(TypeError): + _format_value(object()) + + +# ---------------------------------------------------------------------- # +# CREATE # +# ---------------------------------------------------------------------- # + + +class TestCreate: + def test_post_to_collection_returns_entity_id_header(self, client): + location = ( + f"{ENV_URL}/data/CustomersV3" + "(dataAreaId='usmf',CustomerAccount='TEST')" + ) + resp = _make_response(201, headers={"OData-EntityId": location}) + with patch.object(client._http, "request", return_value=resp) as m: + result = client.records.create("CustomersV3", {"CustomerAccount": "TEST"}) + assert result == location + m.assert_called_once() + args, kwargs = m.call_args + assert args == ("POST", f"{ENV_URL}/data/CustomersV3") + assert kwargs["json"] == {"CustomerAccount": "TEST"} + assert kwargs["headers"]["Content-Type"] == "application/json" + + def test_falls_back_to_response_body_when_no_header(self, client): + body = {"CustomerAccount": "TEST"} + resp = _make_response(201, json_body=body) + with patch.object(client._http, "request", return_value=resp): + assert client.records.create("CustomersV3", {"CustomerAccount": "TEST"}) == body + + def test_rejects_non_mapping_data(self, client): + with pytest.raises(TypeError): + client.records.create("CustomersV3", ["not", "a", "mapping"]) # type: ignore[arg-type] + + +# ---------------------------------------------------------------------- # +# RETRIEVE # +# ---------------------------------------------------------------------- # + + +class TestGet: + def test_get_with_composite_key_and_select(self, client): + body = {"CustomerAccount": "TEST", "OrganizationName": "Acme"} + resp = _make_response(200, json_body=body) + with patch.object(client._http, "request", return_value=resp) as m: + row = client.records.get( + "CustomersV3", + {"dataAreaId": "usmf", "CustomerAccount": "TEST"}, + select=["CustomerAccount", "OrganizationName"], + ) + assert row == body + args, kwargs = m.call_args + assert args == ( + "GET", + f"{ENV_URL}/data/CustomersV3" + "(dataAreaId='usmf',CustomerAccount='TEST')", + ) + assert kwargs["params"] == {"$select": "CustomerAccount,OrganizationName"} + + def test_get_scalar_string_key(self, client): + resp = _make_response(200, json_body={}) + with patch.object(client._http, "request", return_value=resp) as m: + client.records.get("Items", "M0001") + args, _ = m.call_args + assert args[1] == f"{ENV_URL}/data/Items('M0001')" + + +# ---------------------------------------------------------------------- # +# UPDATE # +# ---------------------------------------------------------------------- # + + +class TestUpdate: + def test_patch_with_default_etag(self, client): + resp = _make_response(204) + with patch.object(client._http, "request", return_value=resp) as m: + client.records.update( + "CustomersV3", + {"dataAreaId": "usmf", "CustomerAccount": "TEST"}, + {"OrganizationName": "Updated"}, + ) + args, kwargs = m.call_args + assert args[0] == "PATCH" + assert args[1].endswith("(dataAreaId='usmf',CustomerAccount='TEST')") + assert kwargs["json"] == {"OrganizationName": "Updated"} + assert kwargs["headers"]["If-Match"] == "*" + + def test_patch_with_explicit_etag(self, client): + resp = _make_response(204) + with patch.object(client._http, "request", return_value=resp) as m: + client.records.update( + "Items", "M0001", {"ItemDescription": "x"}, etag='W/"123"' + ) + assert m.call_args.kwargs["headers"]["If-Match"] == 'W/"123"' + + def test_empty_changes_rejected(self, client): + with pytest.raises(ValueError): + client.records.update("Items", "M0001", {}) + + def test_non_mapping_changes_rejected(self, client): + with pytest.raises(TypeError): + client.records.update("Items", "M0001", ["bad"]) # type: ignore[arg-type] + + +# ---------------------------------------------------------------------- # +# DELETE # +# ---------------------------------------------------------------------- # + + +class TestDelete: + def test_delete_with_default_etag(self, client): + resp = _make_response(204) + with patch.object(client._http, "request", return_value=resp) as m: + client.records.delete("Items", "M0001") + args, kwargs = m.call_args + assert args[0] == "DELETE" + assert args[1] == f"{ENV_URL}/data/Items('M0001')" + assert kwargs["headers"]["If-Match"] == "*" + + +# ---------------------------------------------------------------------- # +# Error mapping # +# ---------------------------------------------------------------------- # + + +class TestErrorMapping: + """End-to-end exception hierarchy checks via _http._raise.""" + + def _make_session_response(self, status, headers=None, body=None): + resp = MagicMock() + resp.status_code = status + resp.headers = headers or {} + resp.url = "https://stubbed/x" + resp.request = MagicMock(method="GET", url=resp.url) + if body is not None: + resp.json = MagicMock(return_value=body) + else: + resp.json = MagicMock(side_effect=ValueError()) + resp.text = "" + resp.content = b"{}" + return resp + + def test_404_maps_to_not_found(self, client): + bad = self._make_session_response(404, body={"error": "x"}) + with patch.object(client._http._session, "request", return_value=bad): + with pytest.raises(FinOpsNotFoundError) as ei: + client.records.get("Items", "missing") + assert ei.value.status_code == 404 + + def test_412_maps_to_concurrency(self, client): + bad = self._make_session_response( + 412, + headers={"ms-dyn-activityid": "act-123"}, + body={"error": "etag"}, + ) + with patch.object(client._http._session, "request", return_value=bad): + with pytest.raises(FinOpsConcurrencyError) as ei: + client.records.update("Items", "M0001", {"x": 1}, etag='W/"old"') + assert ei.value.activity_id == "act-123" + + def test_500_maps_to_generic_http_error(self, client): + bad = self._make_session_response(500, body={"error": "boom"}) + with patch.object(client._http._session, "request", return_value=bad): + with pytest.raises(FinOpsHttpError): + client.records.delete("Items", "M0001") + + +# ---------------------------------------------------------------------- # +# Auth wiring # +# ---------------------------------------------------------------------- # + + +class TestAuth: + def test_token_cached_across_calls(self, client): + resp = _make_response(200, json_body={}) + with patch.object(client._http._session, "request", return_value=resp): + client.records.get("Items", "A") + client.records.get("Items", "B") + # _FakeCredential.calls == 1 means token was cached. + assert client._token_provider._credential.calls == 1 # type: ignore[attr-defined] + + def test_default_scope_is_env_dot_default(self, client): + assert client.scope == f"{ENV_URL}/.default" + + def test_data_url_format(self, client): + assert client.data_url == f"{ENV_URL}/data" diff --git a/tests/finops/test_records_list.py b/tests/finops/test_records_list.py new file mode 100644 index 00000000..f281a309 --- /dev/null +++ b/tests/finops/test_records_list.py @@ -0,0 +1,165 @@ +"""Unit tests for ``records.list`` (paginated iterator). + +These tests do not hit a real FinOps environment; they patch +``HttpClient.request`` to assert the SDK builds the right OData URLs and +correctly follows ``@odata.nextLink`` continuation tokens. +""" +from __future__ import annotations + +import time +from unittest.mock import MagicMock, patch + +import pytest + +from PowerPlatform.FinOps import FinOpsClient + +ENV_URL = "https://my-finops-env.cloudax.dynamics.com" + + +class _FakeCredential: + def __init__(self) -> None: + self.calls = 0 + + def get_token(self, *scopes: str, **_: object): + self.calls += 1 + return MagicMock(token="t", expires_on=int(time.time()) + 3600) + + +def _make_response(json_body): + resp = MagicMock() + resp.status_code = 200 + resp.headers = {} + resp.url = "https://stubbed/" + resp.request = MagicMock(method="GET", url="https://stubbed/") + resp.json = MagicMock(return_value=json_body) + resp.content = b"{}" + resp.text = "" + return resp + + +@pytest.fixture +def client(): + c = FinOpsClient(ENV_URL, _FakeCredential()) + yield c + c.close() + + +class TestRecordsList: + def test_yields_single_page(self, client): + page = {"value": [{"id": 1}, {"id": 2}, {"id": 3}]} + with patch.object( + client._http, "request", return_value=_make_response(page) + ) as m: + rows = list(client.records.list("CustomersV3")) + assert rows == [{"id": 1}, {"id": 2}, {"id": 3}] + assert m.call_count == 1 + # First positional args: method, url + args, kwargs = m.call_args + assert args[0] == "GET" + assert args[1] == f"{ENV_URL}/data/CustomersV3" + assert kwargs["params"] is None # no $-options requested + + def test_follows_nextlink_across_pages(self, client): + next_url = f"{ENV_URL}/data/CustomersV3?cookie=abc" + responses = [ + _make_response({"value": [{"id": 1}], "@odata.nextLink": next_url}), + _make_response({"value": [{"id": 2}], "@odata.nextLink": next_url}), + _make_response({"value": [{"id": 3}]}), + ] + with patch.object(client._http, "request", side_effect=responses) as m: + rows = list(client.records.list("CustomersV3")) + assert rows == [{"id": 1}, {"id": 2}, {"id": 3}] + assert m.call_count == 3 + # Page 2 and 3 must be GET against the nextLink URL with params=None. + for call in m.call_args_list[1:]: + assert call.args[1] == next_url + assert call.kwargs["params"] is None + + def test_emits_filter_select_expand_orderby(self, client): + with patch.object( + client._http, "request", return_value=_make_response({"value": []}) + ) as m: + list( + client.records.list( + "CustomersV3", + filter="dataAreaId eq 'usmf'", + select=["CustomerAccount", "OrganizationName"], + expand=["Contacts"], + orderby=["CreatedDateTime desc", "CustomerAccount"], + ) + ) + params = m.call_args.kwargs["params"] + assert params == { + "$filter": "dataAreaId eq 'usmf'", + "$select": "CustomerAccount,OrganizationName", + "$expand": "Contacts", + "$orderby": "CreatedDateTime desc,CustomerAccount", + } + + def test_top_caps_clientside_when_server_overruns(self, client): + # Server returns more than $top requested — SDK must enforce client-side cap. + page = {"value": [{"i": i} for i in range(10)]} + with patch.object( + client._http, "request", return_value=_make_response(page) + ) as m: + rows = list(client.records.list("CustomersV3", top=3)) + assert rows == [{"i": 0}, {"i": 1}, {"i": 2}] + assert m.call_args.kwargs["params"]["$top"] == "3" + + def test_top_zero_yields_nothing_no_request(self, client): + with patch.object(client._http, "request") as m: + rows = list(client.records.list("CustomersV3", top=0)) + assert rows == [] + assert m.call_count == 0 + + def test_page_size_emits_prefer_header(self, client): + with patch.object( + client._http, "request", return_value=_make_response({"value": []}) + ) as m: + list(client.records.list("CustomersV3", page_size=500)) + assert m.call_args.kwargs["headers"] == { + "Prefer": "odata.maxpagesize=500" + } + + def test_page_size_invalid_raises(self, client): + with pytest.raises(ValueError): + # Materialize the generator to trigger the validation. + list(client.records.list("CustomersV3", page_size=0)) + + def test_orderby_string_passthrough(self, client): + with patch.object( + client._http, "request", return_value=_make_response({"value": []}) + ) as m: + list(client.records.list("X", orderby="CreatedDateTime desc")) + assert m.call_args.kwargs["params"]["$orderby"] == "CreatedDateTime desc" + + def test_top_stops_paging_early(self, client): + responses = [ + _make_response({"value": [{"i": 0}, {"i": 1}], "@odata.nextLink": "u"}), + _make_response({"value": [{"i": 2}, {"i": 3}], "@odata.nextLink": "u"}), + ] + with patch.object(client._http, "request", side_effect=responses) as m: + rows = list(client.records.list("X", top=3)) + assert rows == [{"i": 0}, {"i": 1}, {"i": 2}] + # Should have stopped after page 2 yielded the 3rd row. + assert m.call_count == 2 + + +def test_cross_company_flag_emits_query_param(client): + """`cross_company=True` -> `cross-company=true` query string (FinOps OData quirk).""" + with patch.object( + client._http, "request", return_value=_make_response({"value": []}) + ) as m: + list(client.records.list("CustomerGroups", cross_company=True, top=1)) + params = m.call_args.kwargs["params"] + assert params["cross-company"] == "true" + assert params["$top"] == "1" + + +def test_cross_company_default_off(client): + with patch.object( + client._http, "request", return_value=_make_response({"value": []}) + ) as m: + list(client.records.list("CustomerGroups", top=1)) + params = m.call_args.kwargs["params"] + assert "cross-company" not in params