dynamics-client-gh 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ from .base_client import BaseClient
2
+ from .dataverse_client import DataverseClient
3
+ from .token_manager import TokenManager
4
+ from .certificate_token_manager import CertificateTokenManager
5
+ from .exceptions import TokenAcquisitionError
6
+
7
+
8
+ __all__ = ["BaseClient", "DataverseClient", "TokenManager", "TokenAcquisitionError", "CertificateTokenManager"]
@@ -0,0 +1,8 @@
1
+ from typing import Protocol
2
+
3
+ class BaseClient(Protocol):
4
+ def __init__(self, *args, **kwargs):
5
+ ...
6
+
7
+ def read(self):
8
+ ...
@@ -0,0 +1,23 @@
1
+ from abc import ABC
2
+ import time
3
+
4
+
5
+ class BaseTokenManager(ABC):
6
+ def __init__(self, *args, **kwargs):
7
+ ...
8
+
9
+ def get_headers(self) -> dict:
10
+ return {
11
+ "Authorization": f"Bearer {self.get_token()}",
12
+ "Accept": "application/json",
13
+ "OData-MaxVersion": "4.0",
14
+ "OData-Version": "4.0",
15
+ "Content-Type": "application/json; charset=utf-8",
16
+ }
17
+
18
+ def get_token(self) -> str:
19
+ with self._lock:
20
+ if self._access_token and time.time() < self._expires_at - 60:
21
+ return self._access_token
22
+ self._fetch_token()
23
+ return self._access_token
@@ -0,0 +1,144 @@
1
+ import base64
2
+ import binascii
3
+ import threading
4
+
5
+ from azure.core.exceptions import ClientAuthenticationError, HttpResponseError, ResourceNotFoundError
6
+ from azure.identity import CertificateCredential, DefaultAzureCredential
7
+ from azure.keyvault.secrets import SecretClient
8
+ from .base_token_manager import BaseTokenManager
9
+ from .exceptions import CertificateAuthError
10
+
11
+
12
+ class CertificateTokenManager(BaseTokenManager):
13
+ """
14
+ TokenManager-compatible class that authenticates to Dataverse using a
15
+ certificate pulled from Azure Key Vault, instead of a client secret.
16
+
17
+ The certificate's private key material is stored in Key Vault as the
18
+ secret linked to the certificate (standard Key Vault behavior), so it
19
+ is fetched via SecretClient and handed to CertificateCredential.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ tenant_id: str,
25
+ client_id: str,
26
+ dataverse_url: str,
27
+ key_vault_url: str,
28
+ certificate_secret_name: str,
29
+ managed_identity_client_id: str = None,
30
+ ):
31
+ for name, value in (
32
+ ("tenant_id", tenant_id),
33
+ ("client_id", client_id),
34
+ ("dataverse_url", dataverse_url),
35
+ ("key_vault_url", key_vault_url),
36
+ ("certificate_secret_name", certificate_secret_name),
37
+ ):
38
+ if not value:
39
+ raise ValueError(f"'{name}' is required and cannot be empty")
40
+
41
+ self.tenant_id = tenant_id
42
+ self.client_id = client_id
43
+ self.dataverse_url = dataverse_url.rstrip("/")
44
+ self.key_vault_url = key_vault_url
45
+ self.certificate_secret_name = certificate_secret_name
46
+ self.managed_identity_client_id = managed_identity_client_id
47
+
48
+ self._access_token = None
49
+ self._expires_at = 0
50
+ self._lock = threading.Lock()
51
+ self._credential = None # lazily built CertificateCredential
52
+
53
+ def _fetch_certificate_secret(self):
54
+ kv_credential_kwargs = {}
55
+ if self.managed_identity_client_id:
56
+ kv_credential_kwargs["managed_identity_client_id"] = self.managed_identity_client_id
57
+
58
+ try:
59
+ # DefaultAzureCredential uses the Function App's managed identity in
60
+ # Azure, and falls back to local dev credentials (e.g. Azure CLI)
61
+ # when running locally.
62
+ key_vault_credential = DefaultAzureCredential(**kv_credential_kwargs)
63
+ secret_client = SecretClient(vault_url=self.key_vault_url, credential=key_vault_credential)
64
+ return secret_client.get_secret(self.certificate_secret_name)
65
+ except ResourceNotFoundError as e:
66
+ raise CertificateAuthError(
67
+ f"Certificate secret '{self.certificate_secret_name}' was not found in Key Vault "
68
+ f"'{self.key_vault_url}': {e}"
69
+ ) from e
70
+ except ClientAuthenticationError as e:
71
+ raise CertificateAuthError(
72
+ f"Failed to authenticate to Key Vault '{self.key_vault_url}' using the managed "
73
+ f"identity/local credential: {e}"
74
+ ) from e
75
+ except HttpResponseError as e:
76
+ raise CertificateAuthError(
77
+ f"Key Vault request for secret '{self.certificate_secret_name}' failed: {e}"
78
+ ) from e
79
+ except CertificateAuthError:
80
+ raise
81
+ except Exception as e:
82
+ raise CertificateAuthError(
83
+ f"Unexpected error retrieving certificate secret '{self.certificate_secret_name}' "
84
+ f"from Key Vault '{self.key_vault_url}': {e}"
85
+ ) from e
86
+
87
+ def _load_certificate_credential(self) -> CertificateCredential:
88
+ certificate_secret = self._fetch_certificate_secret()
89
+
90
+ if not certificate_secret or not certificate_secret.value:
91
+ raise CertificateAuthError(
92
+ f"Certificate secret '{self.certificate_secret_name}' in Key Vault "
93
+ f"'{self.key_vault_url}' has no value"
94
+ )
95
+
96
+ try:
97
+ pfx_bytes = base64.b64decode(certificate_secret.value, validate=True)
98
+ except (binascii.Error, ValueError) as e:
99
+ raise CertificateAuthError(
100
+ f"Certificate secret '{self.certificate_secret_name}' is not valid base64-encoded "
101
+ f"PFX data: {e}"
102
+ ) from e
103
+
104
+ if not pfx_bytes:
105
+ raise CertificateAuthError(
106
+ f"Certificate secret '{self.certificate_secret_name}' decoded to empty PFX data"
107
+ )
108
+
109
+ try:
110
+ return CertificateCredential(
111
+ tenant_id=self.tenant_id,
112
+ client_id=self.client_id,
113
+ certificate_data=pfx_bytes,
114
+ )
115
+ except ValueError as e:
116
+ raise CertificateAuthError(
117
+ f"Certificate secret '{self.certificate_secret_name}' could not be loaded as a valid "
118
+ f"certificate/private key: {e}"
119
+ ) from e
120
+
121
+ def _fetch_token(self):
122
+ try:
123
+ if self._credential is None:
124
+ self._credential = self._load_certificate_credential()
125
+ token = self._credential.get_token(f"{self.dataverse_url}/.default")
126
+ except CertificateAuthError:
127
+ # Reset so a subsequent call can retry credential creation instead of
128
+ # being stuck with a half-initialized state.
129
+ self._credential = None
130
+ raise
131
+ except ClientAuthenticationError as e:
132
+ self._credential = None
133
+ raise CertificateAuthError(
134
+ f"Failed to acquire a Dataverse access token for '{self.dataverse_url}' using the "
135
+ f"certificate credential: {e}"
136
+ ) from e
137
+ except Exception as e:
138
+ self._credential = None
139
+ raise CertificateAuthError(
140
+ f"Unexpected error acquiring Dataverse access token for '{self.dataverse_url}': {e}"
141
+ ) from e
142
+
143
+ self._access_token = token.token
144
+ self._expires_at = token.expires_on
@@ -0,0 +1,103 @@
1
+ # dynamics_auth/dataverse_client.py
2
+ import requests
3
+
4
+ from .token_manager import TokenManager
5
+ from .certificate_token_manager import CertificateTokenManager
6
+
7
+
8
+ class DataverseError(Exception):
9
+ """Raised when a Dataverse API call fails."""
10
+ pass
11
+
12
+
13
+ class DataverseClient:
14
+ def __init__(self, token_manager: TokenManager, base_url: str, api_version: str = "v9.2", timeout: int = 30):
15
+ self.token_manager = token_manager
16
+ self.base_url = base_url.rstrip("/")
17
+ self.api_version = api_version
18
+ self.timeout = timeout
19
+
20
+ @classmethod
21
+ def with_certificate_auth(
22
+ cls,
23
+ tenant_id: str,
24
+ client_id: str,
25
+ base_url: str,
26
+ key_vault_url: str,
27
+ certificate_secret_name: str,
28
+ managed_identity_client_id: str = None,
29
+ api_version: str = "v9.2",
30
+ timeout: int = 30,
31
+ ) -> "DataverseClient":
32
+ """
33
+ Convenience constructor that wires up certificate-based auth via
34
+ Azure Key Vault instead of the existing secret-based TokenManager.
35
+
36
+ Example:
37
+ client = DataverseClient.with_certificate_auth(
38
+ tenant_id=os.environ["ENTRA_TENANT_ID"],
39
+ client_id=os.environ["DATAVERSE_CLIENT_ID"],
40
+ base_url=os.environ["DATAVERSE_URL"],
41
+ key_vault_url=os.environ["KEY_VAULT_URL"],
42
+ certificate_secret_name=os.environ["DATAVERSE_CERTIFICATE_SECRET_NAME"],
43
+ )
44
+ """
45
+ token_manager = CertificateTokenManager(
46
+ tenant_id=tenant_id,
47
+ client_id=client_id,
48
+ dataverse_url=base_url,
49
+ key_vault_url=key_vault_url,
50
+ certificate_secret_name=certificate_secret_name,
51
+ managed_identity_client_id=managed_identity_client_id,
52
+ )
53
+ return cls(token_manager=token_manager, base_url=base_url, api_version=api_version, timeout=timeout)
54
+
55
+ @property
56
+ def _api_url(self) -> str:
57
+ return f"{self.base_url}/api/data/{self.api_version}"
58
+
59
+ def _headers(self, extra: dict = None) -> dict:
60
+ headers = self.token_manager.get_headers()
61
+ if extra:
62
+ headers.update(extra)
63
+ return headers
64
+
65
+ def _request(self, method: str, path: str, **kwargs) -> requests.Response:
66
+ url = f"{self._api_url}/{path.lstrip('/')}"
67
+ headers = self._headers(kwargs.pop("headers", None))
68
+
69
+ try:
70
+ resp = requests.request(method, url, headers=headers, timeout=self.timeout, **kwargs)
71
+ resp.raise_for_status()
72
+ except requests.RequestException as e:
73
+ raise DataverseError(f"{method} {url} failed: {e}") from e
74
+ return resp
75
+
76
+ # --- generic CRUD ---
77
+
78
+ def get(self, entity_set: str, entity_id: str = None, query: str = "") -> dict:
79
+ path = f"{entity_set}({entity_id})" if entity_id else entity_set
80
+ if query:
81
+ path += f"?{query}"
82
+ resp = self._request("GET", path)
83
+ return resp.json()
84
+
85
+ def get_list(self, entity_set: str, query: str = "") -> list:
86
+ data = self.get(entity_set, query=query)
87
+ return data.get("value", [])
88
+
89
+ def create(self, entity_set: str, payload: dict) -> str:
90
+ resp = self._request("POST", entity_set, json=payload)
91
+ # Dataverse returns the new record's URL in this header
92
+ return resp.headers.get("OData-EntityId")
93
+
94
+ def update(self, entity_set: str, entity_id: str, payload: dict) -> None:
95
+ self._request(
96
+ "PATCH",
97
+ f"{entity_set}({entity_id})",
98
+ json=payload,
99
+ headers={"If-Match": "*"},
100
+ )
101
+
102
+ def delete(self, entity_set: str, entity_id: str) -> None:
103
+ self._request("DELETE", f"{entity_set}({entity_id})")
@@ -0,0 +1,7 @@
1
+ class TokenAcquisitionError(Exception):
2
+ """Raised when the OAuth token request fails."""
3
+ pass
4
+
5
+ class CertificateAuthError(Exception):
6
+ """Raised when certificate-based authentication to Dataverse via Key Vault fails."""
7
+ pass
File without changes
@@ -0,0 +1,65 @@
1
+ import threading
2
+ import requests
3
+
4
+ from .exceptions import TokenAcquisitionError
5
+ from .base_token_manager import BaseTokenManager
6
+
7
+ class TokenManager(BaseTokenManager):
8
+ def __init__(self, tenant_id: str, client_id: str, client_secret: str, resource: str, timeout: int = 10):
9
+ self.tenant_id = tenant_id
10
+ self.client_id = client_id
11
+ self.client_secret = client_secret
12
+ self.resource = resource
13
+ self.timeout = timeout
14
+
15
+ self._access_token = None
16
+ self._expires_at = 0
17
+ self._lock = threading.Lock()
18
+
19
+ def _fetch_token(self):
20
+ try:
21
+ resp = requests.post(
22
+ f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/token",
23
+ data={
24
+ "grant_type": "client_credentials",
25
+ "client_id": self.client_id,
26
+ "client_secret": self.client_secret,
27
+ "resource": self.resource,
28
+ },
29
+ timeout=self.timeout,
30
+ )
31
+ resp.raise_for_status()
32
+ except requests.RequestException as e:
33
+ raise TokenAcquisitionError(f"Failed to acquire token: {e}") from e
34
+
35
+ try:
36
+ data = resp.json()
37
+ except ValueError as e:
38
+ raise TokenAcquisitionError(f"Token endpoint returned a non-JSON response: {e}") from e
39
+
40
+ if not isinstance(data, dict):
41
+ raise TokenAcquisitionError(
42
+ f"Token endpoint returned an unexpected payload type: {type(data).__name__}"
43
+ )
44
+
45
+ access_token = data.get("access_token")
46
+ expires_on = data.get("expires_on")
47
+
48
+ if not access_token:
49
+ raise TokenAcquisitionError(
50
+ f"Token response is missing 'access_token': {data}"
51
+ )
52
+ if expires_on is None:
53
+ raise TokenAcquisitionError(
54
+ f"Token response is missing 'expires_on': {data}"
55
+ )
56
+
57
+ try:
58
+ expires_on = int(expires_on)
59
+ except (TypeError, ValueError) as e:
60
+ raise TokenAcquisitionError(
61
+ f"Token response has a non-integer 'expires_on' value ({expires_on!r}): {e}"
62
+ ) from e
63
+
64
+ self._access_token = access_token
65
+ self._expires_at = expires_on
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.5
2
+ Name: dynamics-client-gh
3
+ Version: 0.1.2
4
+ Summary: Client library to easaly work within the ms dynamics universe
5
+ Author-email: PommereningMartin <m.pommerening@gruenhorn.de>, MichaelWehling <m.wehling@gruenhorn.de>
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: azure-identity
8
+ Requires-Dist: azure-keyvault-secrets
9
+ Requires-Dist: pytest>=9.1.1
10
+ Requires-Dist: requests>=2.34.2
11
+ Description-Content-Type: text/markdown
12
+
13
+ # dynamics-client-gh
14
+
15
+ Simple, thread-safe OAuth2 client-credentials authentication and a lightweight Dataverse (Microsoft Dynamics 365) OData client for Python.
16
+
17
+ ## Features
18
+
19
+ - `TokenManager` — acquires and caches Azure AD access tokens automatically.
20
+ - `DataverseClient` — thin wrapper around the Dataverse Web API with `get`, `get_list`, `create`, `update`, `delete`.
21
+ - Thread-safe, minimal dependencies (`requests` only).
22
+
23
+ ## Installation
24
+
25
+ pip install dynamics-client-pukna
26
+
27
+ ## Requirements
28
+
29
+ - Python 3.8+
30
+ - Azure AD App Registration with a client secret
31
+ - A Dataverse Application User linked to that app, with a security role assigned
32
+
33
+ ## Quick Start
34
+
35
+ from dynamics_client import TokenManager
36
+ from dynamics_client.dataverse_client import DataverseClient
37
+
38
+ token_manager = TokenManager(
39
+ tenant_id="...", client_id="...", client_secret="...",
40
+ resource="https://yourorg.crm.dynamics.com",
41
+ )
42
+
43
+ client = DataverseClient(token_manager=token_manager, base_url="https://yourorg.crm.dynamics.com")
44
+
45
+ accounts = client.get_list("accounts", query="$select=name&$top=5")
46
+
47
+ See the [full documentation](https://github.com/yourname/dynamics-auth#readme) for the API reference, error handling, and building custom entity services.
48
+
49
+ ## License
50
+
51
+ MIT
@@ -0,0 +1,11 @@
1
+ dynamics_client/__init__.py,sha256=6R3nE70mjkZcZ0WIVVxc0ITt1cp-eha_Nzg1SG0g9zQ,351
2
+ dynamics_client/base_client.py,sha256=fGWoJM7mJg5mM_Jhjj1PFQqcq-nqzFKsxMA7uHCsWuQ,149
3
+ dynamics_client/base_token_manager.py,sha256=sPWOGUaPb5lFMgqjSyULjb21OfFHAiXr6IQrXY6u1vo,684
4
+ dynamics_client/certificate_token_manager.py,sha256=8wRQ70rqZ6foFGCqEB86ZG6g2CBzZVhjHjQAQExL7QY,6137
5
+ dynamics_client/dataverse_client.py,sha256=a5maUfv-dx04rrnct3CuaHetB9GVNNcUQ2SryFhcmeI,3837
6
+ dynamics_client/exceptions.py,sha256=uMy9PnP2mCiN5ktO40RQL5G4_mOdULNQEFjHTfFtpWE,245
7
+ dynamics_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ dynamics_client/token_manager.py,sha256=L-Z0VPK_ZKgZ7nCJ8YyMBkzCzj3RC09tWJEzWTIQwiQ,2345
9
+ dynamics_client_gh-0.1.2.dist-info/METADATA,sha256=cIy0xs8FhqITYNXbHkL1PjSU9OH4e3g00l14hWPstoQ,1665
10
+ dynamics_client_gh-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ dynamics_client_gh-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any