Skip to content

Commit 9c27611

Browse files
authored
chore: [CDF-25309] Introduce service principals data classes (#2213)
1 parent f89cacb commit 9c27611

4 files changed

Lines changed: 298 additions & 3 deletions

File tree

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
from __future__ import annotations
2+
3+
from abc import ABC, abstractmethod
4+
from dataclasses import dataclass
5+
from typing import TYPE_CHECKING, Any, ClassVar, cast
6+
7+
from typing_extensions import Self
8+
9+
from cognite.client.data_classes._base import (
10+
CogniteObject,
11+
CogniteResource,
12+
CogniteResourceList,
13+
)
14+
15+
if TYPE_CHECKING:
16+
from cognite.client import CogniteClient
17+
18+
19+
class Principal(CogniteResource, ABC):
20+
_type: ClassVar[str]
21+
22+
def __init__(self, id: str) -> None:
23+
self.id = id
24+
25+
@classmethod
26+
@abstractmethod
27+
def _load_principal(cls, resource: dict[str, Any]) -> Self:
28+
"""Load a principal from a resource dictionary."""
29+
raise NotImplementedError("This method should be implemented in subclasses.")
30+
31+
@classmethod
32+
def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> Self:
33+
type_ = resource.get("type")
34+
if type_ is None and hasattr(cls, "_type"):
35+
type_ = cls._type
36+
elif type_ is None:
37+
raise KeyError("type")
38+
principal_cls = _PRINCIPAL_CLS_BY_TYPE.get(type_.lower())
39+
if principal_cls is None:
40+
return UnknownPrincipal._load_principal(resource) # type: ignore[return-value]
41+
return cast(
42+
Self,
43+
principal_cls._load_principal(
44+
resource,
45+
),
46+
)
47+
48+
def dump(self, camel_case: bool = True) -> dict[str, Any]:
49+
"""Dump the principal to a dictionary."""
50+
output = super().dump(camel_case=camel_case)
51+
output["type"] = self._type.upper()
52+
return output
53+
54+
55+
class UserPrincipal(Principal):
56+
"""Represents a user principal in Cognite Data Fusion (CDF).
57+
58+
Arguments:
59+
id (str): The ID of an organization user
60+
name (str): Human-readable name of the principal
61+
picture_url (str): URL to a picture of the principal
62+
email (str | None): User email. Do not use this to uniquely identify a user, as it can be changed
63+
and is not guaranteed to be unique. Use the id field instead.
64+
given_name (str | None): The given name of the user
65+
middle_name (str | None): The middle name of the user
66+
family_name (str | None): The family name of the user
67+
68+
"""
69+
70+
_type = "user"
71+
72+
def __init__(
73+
self,
74+
id: str,
75+
name: str,
76+
picture_url: str,
77+
email: str | None = None,
78+
given_name: str | None = None,
79+
middle_name: str | None = None,
80+
family_name: str | None = None,
81+
) -> None:
82+
super().__init__(id)
83+
self.name = name
84+
self.picture_url = picture_url
85+
self.email = email
86+
self.given_name = given_name
87+
self.middle_name = middle_name
88+
self.family_name = family_name
89+
90+
@classmethod
91+
def _load_principal(cls, resource: dict[str, Any]) -> Self:
92+
return cls(
93+
id=resource["id"],
94+
name=resource["name"],
95+
picture_url=resource["pictureUrl"],
96+
email=resource.get("email"),
97+
given_name=resource.get("givenName"),
98+
middle_name=resource.get("middleName"),
99+
family_name=resource.get("familyName"),
100+
)
101+
102+
103+
@dataclass
104+
class ServiceAccountCreator(CogniteObject):
105+
"""The creator of a service account.
106+
107+
Arguments:
108+
org_id (str): The ID of an organization.
109+
user_id (str): The ID of an organization user
110+
111+
"""
112+
113+
org_id: str
114+
user_id: str
115+
116+
@classmethod
117+
def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> Self:
118+
return cls(org_id=resource["orgId"], user_id=resource["userId"])
119+
120+
121+
class ServicePrincipal(Principal):
122+
"""Represents a service account principal in Cognite Data Fusion (CDF).
123+
124+
Arguments:
125+
id (str): Unique identifier of a service account
126+
name (str): Human-readable name of the service account
127+
created_by (ServiceAccountCreator): The creator of the service account
128+
created_time (int): When the principal was created. It is given as the number of milliseconds
129+
since 00:00:00 Thursday, 1 January 1970, Coordinated Universal Time (UTC), minus leap seconds.
130+
last_updated_time (int): When the principal was last updated. It is given as the number of milliseconds
131+
since 00:00:00 Thursday, 1 January 1970, Coordinated Universal Time (UTC), minus leap seconds.
132+
picture_url (str): URL to a picture of the principal.
133+
external_id (str | None): The external ID provided by the client. Must be unique for the resource type.
134+
description (str | None): A description of the service account.
135+
136+
"""
137+
138+
_type = "service_account"
139+
140+
def __init__(
141+
self,
142+
id: str,
143+
name: str,
144+
created_by: ServiceAccountCreator,
145+
created_time: int,
146+
last_updated_time: int,
147+
picture_url: str,
148+
external_id: str | None = None,
149+
description: str | None = None,
150+
) -> None:
151+
super().__init__(id)
152+
self.name = name
153+
self.created_by = created_by
154+
self.created_time = created_time
155+
self.last_updated_time = last_updated_time
156+
self.picture_url = picture_url
157+
self.external_id = external_id
158+
self.description = description
159+
160+
@classmethod
161+
def _load_principal(cls, resource: dict[str, Any]) -> Self:
162+
return cls(
163+
id=resource["id"],
164+
name=resource["name"],
165+
created_by=ServiceAccountCreator._load(resource["createdBy"]),
166+
created_time=resource["createdTime"],
167+
last_updated_time=resource["lastUpdatedTime"],
168+
picture_url=resource["pictureUrl"],
169+
external_id=resource.get("externalId"),
170+
description=resource.get("description"),
171+
)
172+
173+
def dump(self, camel_case: bool = True) -> dict[str, Any]:
174+
output = super().dump(camel_case=camel_case)
175+
output["createdBy" if camel_case else "created_by"] = self.created_by.dump(camel_case=camel_case)
176+
return output
177+
178+
179+
class UnknownPrincipal(Principal):
180+
"""Represents an unknown principal in Cognite Data Fusion (CDF).
181+
182+
This class is used when the principal type is not recognized or not defined in the SDK.
183+
Typically, this can happen when a new type of principal is introduced in CDF that is not yet supported by the SDK.
184+
185+
Arguments:
186+
id (str): Unique identifier of the principal.
187+
type (str): The type of the principal, which is not recognized by the SDK.
188+
data (dict[str, Any]): Additional data associated with the principal, excluding the 'id' and 'type' fields.
189+
190+
"""
191+
192+
_type = "unknown"
193+
194+
def __init__(self, id: str, type: str, data: dict[str, Any]) -> None:
195+
super().__init__(id)
196+
self.type = type
197+
self.__data = data
198+
199+
@classmethod
200+
def _load_principal(cls, resource: dict[str, Any]) -> Self:
201+
return cls(
202+
id=resource["id"],
203+
type=resource["type"],
204+
data={k: v for k, v in resource.items() if k not in {"id", "type"}},
205+
)
206+
207+
def dump(self, camel_case: bool = True) -> dict[str, Any]:
208+
return {"id": self.id, "type": self.type, **self.__data}
209+
210+
211+
class PrincipalList(CogniteResourceList[Principal]):
212+
_RESOURCE = Principal
213+
214+
def as_ids(self) -> list[str]:
215+
"""Returns a list of principal IDs."""
216+
return [principal.id for principal in self]
217+
218+
219+
# Build the mapping AFTER all classes are defined
220+
_PRINCIPAL_CLS_BY_TYPE: dict[str, type[Principal]] = {
221+
subclass._type: subclass # type: ignore[type-abstract]
222+
for subclass in Principal.__subclasses__()
223+
if hasattr(subclass, "_type") and not getattr(subclass, "__abstractmethods__", None)
224+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import pytest
2+
3+
from cognite.client.data_classes.principals import Principal, ServicePrincipal, UnknownPrincipal, UserPrincipal
4+
5+
6+
class TestPrincipals:
7+
@pytest.mark.parametrize(
8+
"data, expected_cls",
9+
[
10+
pytest.param(
11+
{
12+
"id": "123",
13+
"name": "test_principal",
14+
"type": "USER",
15+
"givenName": "Test",
16+
"familyName": "Principal",
17+
"middleName": "Middle",
18+
"pictureUrl": "http://example.com/picture.jpg",
19+
},
20+
UserPrincipal,
21+
id="user_principal",
22+
),
23+
pytest.param(
24+
{
25+
"id": "456",
26+
"name": "another_principal",
27+
"type": "SERVICE_ACCOUNT",
28+
"createdBy": {
29+
"orgId": "org_123",
30+
"userId": "user_456",
31+
},
32+
"createdTime": 1622547800,
33+
"lastUpdatedTime": 1622547800,
34+
"pictureUrl": "http://example.com/service_principal.jpg",
35+
"externalId": "service_principal_123",
36+
"description": "A service principal for testing purposes",
37+
},
38+
ServicePrincipal,
39+
id="service principal",
40+
),
41+
pytest.param(
42+
{
43+
"id": "some_id",
44+
"type": "NEW_TYPE",
45+
"name": "new_name",
46+
"someOtherField": {"nestedField": "value"},
47+
},
48+
UnknownPrincipal,
49+
id="unknown_principal",
50+
),
51+
],
52+
)
53+
def test_dump_load(self, data: dict[str, object], expected_cls: type[Principal]) -> None:
54+
loaded = Principal._load(data)
55+
56+
assert isinstance(loaded, expected_cls), (
57+
f"Loaded principal is not of type {expected_cls.__name__}: {type(loaded).__name__}"
58+
)
59+
60+
dumped = loaded.dump(camel_case=True)
61+
62+
assert dumped == data, f"Dumped data does not match original: {dumped} != {data}"

tests/tests_unit/test_meta.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
InternalIdTransformerMixin,
1313
)
1414
from cognite.client.data_classes.datapoints import DatapointsArrayList, DatapointsList
15+
from cognite.client.data_classes.principals import PrincipalList
1516
from tests.utils import all_concrete_subclasses, all_subclasses
1617

1718
ALL_FILEPATHS = Path("cognite/client/").rglob("*.py")
@@ -87,7 +88,15 @@ def test_all_base_api_paths_have_retry_or_specifically_no_set(
8788
assert not (has_retry and no_retry_needed)
8889

8990

90-
@pytest.mark.parametrize("lst_cls", all_concrete_subclasses(CogniteResourceList))
91+
@pytest.mark.parametrize(
92+
"lst_cls",
93+
[
94+
list_cls
95+
# Principal list .as_ids() returns a list of strings and not integers,
96+
# so we skip the check for it.
97+
for list_cls in all_concrete_subclasses(CogniteResourceList, exclude={PrincipalList})
98+
],
99+
)
91100
def test_ensure_identifier_mixins(lst_cls):
92101
# TODO: Data Modeling uses "as_ids()" even though existing classes use the same for "integer internal ids"
93102
if "data_modeling" in str(lst_cls):

tests/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,10 @@ def all_subclasses(base: T_Type, exclude: set[type] | None = None) -> list[T_Typ
9797
)
9898

9999

100-
def all_concrete_subclasses(base: T_Type) -> list[T_Type]:
100+
def all_concrete_subclasses(base: T_Type, exclude: set[type] | None = None) -> list[T_Type]:
101101
return [
102102
sub
103-
for sub in all_subclasses(base)
103+
for sub in all_subclasses(base, exclude=exclude)
104104
if all(base is not abc.ABC for base in sub.__bases__)
105105
and not inspect.isabstract(sub)
106106
# The FakeCogniteResourceGenerator does not support descriptors, so we exclude the Typed classes

0 commit comments

Comments
 (0)