Skip to content

Commit e9647d8

Browse files
riverma12Copilot
andcommitted
FinOps SDK Step 1: CRUD over OData /data/{EntitySet}
Implements the four 'Yes' rows from the FinOps-SDK-Plan capability matrix: - POST /data/{EntitySet} -> records.create - GET /data/{EntitySet}({key}) -> records.get - PATCH /data/{EntitySet}({key}) -> records.update - DELETE /data/{EntitySet}({key}) -> records.delete Package layout under src/PowerPlatform/FinOps: - client.FinOpsClient (env URL, scope=<env>/.default, lifecycle, .records) - _auth.TokenProvider (thread-safe AccessToken cache, 5-min refresh skew) - _http.HttpClient (retries 408/429/5xx with Retry-After + jitter, 401 reauth, OData v4 headers) - errors: FinOpsError -> AuthError | HttpError | NotFound | Concurrency | Throttled - operations.records.RecordOperations (composite-key formatter, OData literal escaping) Tests: 23 unit tests in tests/finops/ (all passing) covering URL/method/header/body shape, composite-key formatting, error mapping (404/412/500), token caching. Verified end-to-end against a live FinOps env (4838 entity sets enumerated; GET + PATCH round-trip on LegalEntities; CREATE returns 201 + parsed Location header). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7441b5c commit e9647d8

10 files changed

Lines changed: 1091 additions & 0 deletions

File tree

scratch_finops_smoketest.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""FinOps SDK smoke test — exercises the four CRUD operations against a real
2+
Dynamics 365 Finance & Operations environment.
3+
4+
This script is a manual harness (not part of pytest). It is the FinOps analogue
5+
of ``scratch_smoketest.py`` for the Dataverse SDK.
6+
7+
USAGE
8+
-----
9+
1. ``az login --tenant <tenant-id>`` (must have FinOps system access)
10+
2. Set the environment variables below.
11+
3. ``python scratch_finops_smoketest.py``
12+
13+
ENV VARS
14+
--------
15+
* FNO_ENV_URL — e.g. https://my-env.operations.int.dynamics.com
16+
* FNO_TENANT_ID — Entra tenant GUID
17+
18+
SAFE-DEFAULT STRATEGY
19+
---------------------
20+
Empty/sandbox FinOps envs typically reject CREATE on master entities like
21+
CustomersV3 / CustomerGroups because they have unsatisfied mandatory references
22+
(Currency, payment terms, tax groups, ...). To prove all four CRUD verbs
23+
without depending on env-specific reference data, this script:
24+
25+
1. READs the singleton row from the ``LegalEntities`` entity set (always present).
26+
2. PATCHes a benign string field (``Name``) to a marker value.
27+
3. READs back to confirm the round-trip.
28+
4. PATCHes back to the original value.
29+
5. Attempts a CREATE + DELETE on ``CustomerGroups`` (best-effort; surfaces any
30+
env-validation errors).
31+
32+
Steps 1-4 prove GET + UPDATE end-to-end. Step 5 proves CREATE + DELETE wire
33+
format (success or controlled failure with structured error body).
34+
"""
35+
from __future__ import annotations
36+
37+
import os
38+
import sys
39+
40+
from azure.identity import AzureCliCredential
41+
42+
from PowerPlatform.FinOps import FinOpsClient, FinOpsHttpError
43+
44+
45+
ENV_URL = os.environ.get("FNO_ENV_URL", "https://<your-finops-env>.operations.int.dynamics.com")
46+
TENANT = os.environ.get("FNO_TENANT_ID", "")
47+
48+
49+
def main() -> int:
50+
if "<your-finops-env>" in ENV_URL or not TENANT:
51+
print("Set FNO_ENV_URL and FNO_TENANT_ID environment variables first.",
52+
file=sys.stderr)
53+
print(" $env:FNO_ENV_URL = 'https://my-env.operations.int.dynamics.com'", file=sys.stderr)
54+
print(" $env:FNO_TENANT_ID = '<tenant guid>'", file=sys.stderr)
55+
return 2
56+
57+
cred = AzureCliCredential(tenant_id=TENANT, process_timeout=120)
58+
59+
with FinOpsClient(ENV_URL, cred) as client:
60+
print(f"== Connected to {client.environment_url} ==")
61+
62+
# ------- 1. service document sanity ------------------------------
63+
sd = client._http.request("GET", client.data_url, expected=(200,))
64+
sets = [v["name"] for v in sd.json().get("value", [])]
65+
print(f"Service document OK — {len(sets)} entity sets exposed")
66+
67+
# ------- 2. GET + UPDATE round-trip on LegalEntities -------------
68+
leg = client._http.request(
69+
"GET", f"{client.data_url}/LegalEntities",
70+
params={"$top": "1"}, expected=(200,),
71+
).json()["value"]
72+
if not leg:
73+
print("LegalEntities is empty — cannot run UPDATE round-trip.", file=sys.stderr)
74+
return 3
75+
leid = leg[0]["LegalEntityId"]
76+
orig = leg[0].get("Name") or ""
77+
print(f"LegalEntity: {leid!r} Name={orig!r}")
78+
79+
row = client.records.get("LegalEntities", leid)
80+
print(f" records.get -> {len(row)} columns")
81+
82+
marker = "FinOps-SDK-Step1-smoketest"
83+
client.records.update("LegalEntities", leid, {"Name": marker})
84+
after = client.records.get("LegalEntities", leid)
85+
assert after.get("Name") == marker, (
86+
f"UPDATE round-trip failed: expected {marker!r}, got {after.get('Name')!r}"
87+
)
88+
print(f" records.update + records.get round-trip verified ({marker!r})")
89+
90+
client.records.update("LegalEntities", leid, {"Name": orig})
91+
restored = client.records.get("LegalEntities", leid)
92+
assert restored.get("Name") == orig, "Failed to restore original Name"
93+
print(f" Restored Name to {orig!r}")
94+
95+
# ------- 3. CREATE + DELETE on CustomerGroups (best-effort) ------
96+
key = {"dataAreaId": "usmf", "CustomerGroupId": "SDK01"}
97+
try:
98+
client.records.delete("CustomerGroups", key)
99+
print("Pre-cleanup CustomerGroups SDK01 -> deleted")
100+
except FinOpsHttpError as e:
101+
print(f"Pre-cleanup CustomerGroups SDK01 -> {e.status_code} (skipped)")
102+
103+
try:
104+
loc = client.records.create("CustomerGroups", {
105+
"dataAreaId": "usmf",
106+
"CustomerGroupId": "SDK01",
107+
"Description": "FinOps SDK smoke test",
108+
})
109+
print("CREATE -> ", loc)
110+
except FinOpsHttpError as e:
111+
print(f"CREATE rejected by env validation ({e.status_code}); body excerpt:")
112+
print(" ", str(e.response_body)[:300])
113+
114+
try:
115+
client.records.delete("CustomerGroups", key)
116+
print("DELETE -> ok")
117+
except FinOpsHttpError as e:
118+
print(f"DELETE -> {e.status_code} (row not present in this env)")
119+
120+
print("== FinOps CRUD smoke test PASSED ==")
121+
return 0
122+
123+
124+
if __name__ == "__main__":
125+
raise SystemExit(main())
126+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""PowerPlatform.FinOps — Python SDK for Microsoft Dynamics 365 Finance & Operations.
2+
3+
Step 1 (this branch) covers the four CRUD operations against the FinOps OData
4+
endpoint (``/data/{EntitySet}``). See ``FinOps-SDK-Plan.docx`` for the full roadmap.
5+
"""
6+
from .client import FinOpsClient
7+
from .errors import (
8+
FinOpsError,
9+
FinOpsAuthError,
10+
FinOpsHttpError,
11+
FinOpsNotFoundError,
12+
FinOpsConcurrencyError,
13+
FinOpsThrottledError,
14+
)
15+
16+
__all__ = [
17+
"FinOpsClient",
18+
"FinOpsError",
19+
"FinOpsAuthError",
20+
"FinOpsHttpError",
21+
"FinOpsNotFoundError",
22+
"FinOpsConcurrencyError",
23+
"FinOpsThrottledError",
24+
]
25+
__version__ = "0.0.1.dev0"

src/PowerPlatform/FinOps/_auth.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Token acquisition + caching for the FinOps SDK.
2+
3+
Wraps any ``azure.core.credentials.TokenCredential`` (e.g. the credentials in
4+
``azure-identity``). Caches the bearer token in memory and proactively refreshes
5+
it shortly before expiry.
6+
7+
Per Platform/AX.Owin/FinOpsAuthenticationOptionsProvider.cs, FinOps tokens are
8+
short-lived and the recommended client cadence is to refresh every ~5 minutes;
9+
we conservatively refresh when fewer than ``REFRESH_SKEW_SECONDS`` remain.
10+
"""
11+
from __future__ import annotations
12+
13+
import threading
14+
import time
15+
from typing import TYPE_CHECKING, Optional
16+
17+
from .errors import FinOpsAuthError
18+
19+
if TYPE_CHECKING: # pragma: no cover
20+
from azure.core.credentials import AccessToken, TokenCredential
21+
22+
23+
# Refresh the cached token when this many seconds (or fewer) remain on it.
24+
REFRESH_SKEW_SECONDS = 300 # 5 min
25+
26+
27+
class TokenProvider:
28+
"""Thread-safe access-token cache for a single FinOps environment."""
29+
30+
def __init__(self, credential: "TokenCredential", scope: str) -> None:
31+
if not scope:
32+
raise ValueError("scope must be a non-empty string")
33+
self._credential = credential
34+
self._scope = scope
35+
self._lock = threading.Lock()
36+
self._token: Optional["AccessToken"] = None
37+
38+
@property
39+
def scope(self) -> str:
40+
return self._scope
41+
42+
def get_bearer(self) -> str:
43+
"""Return a valid bearer token, refreshing in-place if needed."""
44+
token = self._token
45+
if token is None or self._needs_refresh(token):
46+
with self._lock:
47+
token = self._token
48+
if token is None or self._needs_refresh(token):
49+
token = self._acquire()
50+
self._token = token
51+
return token.token
52+
53+
def invalidate(self) -> None:
54+
"""Drop the cached token (forces a fresh acquisition next call)."""
55+
with self._lock:
56+
self._token = None
57+
58+
# -- internals -------------------------------------------------------
59+
60+
@staticmethod
61+
def _needs_refresh(token: "AccessToken") -> bool:
62+
return token.expires_on - time.time() <= REFRESH_SKEW_SECONDS
63+
64+
def _acquire(self) -> "AccessToken":
65+
try:
66+
return self._credential.get_token(self._scope)
67+
except Exception as exc: # pragma: no cover - re-raised
68+
raise FinOpsAuthError(
69+
f"Failed to acquire token for scope {self._scope!r}: {exc}"
70+
) from exc

0 commit comments

Comments
 (0)