diff --git a/cognite/client/_api/iam.py b/cognite/client/_api/iam.py index 7977334c38..ce05bb8a4e 100644 --- a/cognite/client/_api/iam.py +++ b/cognite/client/_api/iam.py @@ -7,6 +7,7 @@ from typing_extensions import TypeAlias +from cognite.client._api.projects import ProjectsAPI from cognite.client._api.user_profiles import UserProfilesAPI from cognite.client._api_client import APIClient from cognite.client._constants import DEFAULT_LIMIT_READ @@ -89,6 +90,7 @@ def _convert_capability_to_tuples(capabilities: ComparableCapability, project: s class IAMAPI(APIClient): def __init__(self, config: ClientConfig, api_version: str | None, cognite_client: CogniteClient) -> None: super().__init__(config, api_version, cognite_client) + self.projects = ProjectsAPI(config, api_version, cognite_client) self.groups = GroupsAPI(config, api_version, cognite_client) self.security_categories = SecurityCategoriesAPI(config, api_version, cognite_client) self.sessions = SessionsAPI(config, api_version, cognite_client) diff --git a/cognite/client/_api/projects.py b/cognite/client/_api/projects.py new file mode 100644 index 0000000000..090b8596ef --- /dev/null +++ b/cognite/client/_api/projects.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from typing import Sequence, overload +from urllib.parse import quote + +from cognite.client._api_client import APIClient +from cognite.client.data_classes import Project, ProjectList, ProjectUpdate, ProjectURLNameList, ProjectWrite + + +class ProjectsAPI(APIClient): + _RESOURCE_PATH = "/projects" + + @overload + def create(self, item: ProjectWrite) -> Project: + ... + + @overload + def create(self, item: Sequence[ProjectWrite]) -> ProjectList: + ... + + def create(self, item: ProjectWrite | Sequence[ProjectWrite]) -> Project | ProjectList: + """`Create a project `_ + + Args: + item (ProjectWrite | Sequence[ProjectWrite]): Project(s) to create + + Returns: + Project | ProjectList: Created project(s) + + Examples: + Create a new project with the name "my project" + + >>> from cognite.client import CogniteClient + >>> from cognite.client.data_classes import ProjectWrite + >>> c = CogniteClient() + >>> project = ProjectWrite(name="my project", url_name="my-project", parent_project_url_name="root") + >>> res = c.iam.projects.create(project) + """ + return self._create_multiple(item, list_cls=ProjectList, resource_cls=Project, input_resource_cls=ProjectWrite) + + def retrieve(self, project: str) -> Project: + """`Retrieve a project `_ + + Args: + project (str): Project to retrieve + + Returns: + Project: The requested project + + Examples: + + Retrieve the project with the name "publicdata" + + >>> from cognite.client import CogniteClient + >>> c = CogniteClient() + >>> res = c.iam.projects.retrieve("publicdata") + """ + item = self._get(f"{self._RESOURCE_PATH}/{quote(project, '')}") + return Project._load(item.json(), cognite_client=self._cognite_client) + + def update(self, item: ProjectUpdate) -> Project: + """`Update a project `_ + + Args: + item (ProjectUpdate): Project to update + + Returns: + Project: Updated project + + Examples: + + >>> from cognite.client import CogniteClient + >>> from cognite.client.data_classes import ProjectUpdate + >>> c = CogniteClient() + >>> my_update = ProjectUpdate("my_project").name.set("new name").oidc_configuration.modify.skew_ms.set(100) + >>> res = c.iam.projects.update(my_update) + """ + project = item._project + response = self._post( + url_path=f"{self._RESOURCE_PATH}/{quote(project, '')}/update", json=item.dump(camel_case=True) + ) + return Project._load(response.json(), cognite_client=self._cognite_client) + + def list(self) -> ProjectURLNameList: + """`List all projects `_ + + Returns: + ProjectURLNameList: List of project URL names + + Examples: + + List all projects + + >>> from cognite.client import CogniteClient + >>> c = CogniteClient() + >>> res = c.iam.projects.list() + """ + items = self._get(self._RESOURCE_PATH) + return ProjectURLNameList.load(items.json(), cognite_client=self._cognite_client) diff --git a/cognite/client/data_classes/__init__.py b/cognite/client/data_classes/__init__.py index 7f0fb128ab..d5099993aa 100644 --- a/cognite/client/data_classes/__init__.py +++ b/cognite/client/data_classes/__init__.py @@ -151,6 +151,17 @@ LabelDefinitionWrite, LabelFilter, ) +from cognite.client.data_classes.projects import ( + Claim, + OIDCConfiguration, + Project, + ProjectList, + ProjectUpdate, + ProjectURLName, + ProjectURLNameList, + ProjectWrite, + ProjectWriteList, +) from cognite.client.data_classes.raw import ( Database, DatabaseList, @@ -276,7 +287,7 @@ TransformationSchemaColumn, TransformationSchemaColumnList, ) -from cognite.client.data_classes.user_profiles import UserProfile, UserProfileList +from cognite.client.data_classes.user_profiles import UserProfile, UserProfileList, UserProfilesConfiguration from cognite.client.data_classes.workflows import ( CancelExecution, CDFTaskOutput, @@ -531,6 +542,7 @@ "CoordinateReferenceSystem", "UserProfile", "UserProfileList", + "UserProfilesConfiguration", "CancelExecution", "WorkflowUpsert", "WorkflowExecution", @@ -557,4 +569,13 @@ "WorkflowTask", "WorkflowUpsertList", "WorkflowVersionUpsertList", + "Project", + "ProjectUpdate", + "ProjectWrite", + "ProjectURLName", + "ProjectURLNameList", + "ProjectWriteList", + "ProjectList", + "OIDCConfiguration", + "Claim", ] diff --git a/cognite/client/data_classes/projects.py b/cognite/client/data_classes/projects.py new file mode 100644 index 0000000000..4b09947d82 --- /dev/null +++ b/cognite/client/data_classes/projects.py @@ -0,0 +1,496 @@ +from __future__ import annotations + +from abc import ABC +from typing import TYPE_CHECKING, Any, Generic, Literal, Sequence + +from cognite.client.data_classes._base import ( + CogniteObject, + CognitePrimitiveUpdate, + CogniteResource, + CogniteResourceList, + CogniteUpdate, + PropertySpec, + T_CogniteUpdate, + WriteableCogniteResource, +) +from cognite.client.data_classes.user_profiles import UserProfilesConfiguration + +if TYPE_CHECKING: + from cognite.client import CogniteClient + + +class Claim(CogniteObject): + """A claim is a property of a token that can be used to grant access to CDF resources. + + Args: + claim_name (str): The name of the claim. + """ + + def __init__(self, claim_name: str) -> None: + self.claim_name = claim_name + + @classmethod + def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> Claim: + return cls(claim_name=resource["claimName"]) + + +class OIDCConfiguration(CogniteObject): + """OIDC configuration for a project. + + Args: + jwks_url (str): The URL where the signing keys used to sign tokens from the identity provider are located + issuer (str): The expected issuer value. + audience (str): The expected audience value (for CDF). + access_claims (list[Claim]): Which claims to link CDF groups to, in order to grant access. + scope_claims (list[Claim]): Which claims to use when scoping access granted by access claims. + log_claims (list[Claim]): Which token claims to record in the audit log. + token_url (str | None): The URL of the OAuth 2.0 token endpoint. + skew_ms (int | None): The allowed skew in milliseconds. + is_group_callback_enabled (bool | None): A group callback occurs when a user has too many groups attached. This property indicates whether the group callback functionality should be supported for this project. This is only supported for AAD hosted IdPs. + identity_provider_scope (str | None): The scope sent to the identity provider when a session is created. The default value is the value required for a default Azure AD IdP configuration. + + """ + + def __init__( + self, + jwks_url: str, + issuer: str, + audience: str, + access_claims: list[Claim], + scope_claims: list[Claim], + log_claims: list[Claim], + token_url: str | None = None, + skew_ms: int | None = None, + is_group_callback_enabled: bool | None = None, + identity_provider_scope: str | None = None, + ) -> None: + self.jwks_url = jwks_url + self.issuer = issuer + self.audience = audience + self.access_claims = access_claims + self.scope_claims = scope_claims + self.log_claims = log_claims + self.token_url = token_url + self.skew_ms = skew_ms + self.is_group_callback_enabled = is_group_callback_enabled + self.identity_provider_scope = identity_provider_scope + + @classmethod + def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> OIDCConfiguration: + return cls( + jwks_url=resource["jwksUrl"], + issuer=resource["issuer"], + audience=resource["audience"], + access_claims=[Claim.load(claim) for claim in resource["accessClaims"]], + scope_claims=[Claim.load(claim) for claim in resource["scopeClaims"]], + log_claims=[Claim.load(claim) for claim in resource["logClaims"]], + token_url=resource.get("tokenUrl"), + skew_ms=resource.get("skewMs"), + is_group_callback_enabled=resource.get("isGroupCallbackEnabled"), + identity_provider_scope=resource.get("identityProviderScope"), + ) + + def dump(self, camel_case: bool = True) -> dict[str, Any]: + output: dict[str, Any] = { + "jwksUrl" if camel_case else "jwks_url": self.jwks_url, + "issuer": self.issuer, + "audience": self.audience, + "accessClaims" if camel_case else "access_claims": [claim.dump(camel_case) for claim in self.access_claims], + "scopeClaims" if camel_case else "scope_claims": [claim.dump(camel_case) for claim in self.scope_claims], + "logClaims" if camel_case else "log_claims": [claim.dump(camel_case) for claim in self.log_claims], + } + if self.token_url is not None: + output["tokenUrl" if camel_case else "token_url"] = self.token_url + if self.skew_ms is not None: + output["skewMs" if camel_case else "skew_ms"] = self.skew_ms + if self.is_group_callback_enabled is not None: + output[ + "isGroupCallbackEnabled" if camel_case else "is_group_callback_enabled" + ] = self.is_group_callback_enabled + if self.identity_provider_scope is not None: + output["identityProviderScope" if camel_case else "identity_provider_scope"] = self.identity_provider_scope + return output + + +class ProjectURLName(CogniteResource): + """A project URL name is a unique identifier for a project. + + Args: + url_name (str): The URL name of the project. This is used as part of the request path in API calls. + Valid URL names contain between 3 and 32 characters, and may only contain English letters, digits and hyphens, + must contain at least one letter and may not start or end with a hyphen. + """ + + def __init__(self, url_name: str) -> None: + self.url_name = url_name + + @classmethod + def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> ProjectURLName: + return cls(url_name=resource["urlName"]) + + +class ProjectCore(WriteableCogniteResource["ProjectWrite"], ABC): + """Projects are used to isolate data in CDF rom each other. All objects in CDF belong to a single project, + and objects in different projects are isolated from each other. + + Typically, we would have at least one dev and a prod project. The dev project is used for development and testing, + while the prod project is used for production data. + + Args: + name (str): The user-friendly name of the project. + url_name (str): The URL name of the project. This is used as part of the request path in API calls. Valid URL names contain between 3 and 32 characters, and may only contain English letters, digits and hyphens, must contain at least one letter and may not start or end with a hyphen. + oidc_configuration (OIDCConfiguration | None): The OIDC configuration for the project. + """ + + def __init__( + self, + name: str, + url_name: str, + oidc_configuration: OIDCConfiguration | None = None, + ) -> None: + self.name = name + self.url_name = url_name + self.oidc_configuration = oidc_configuration + + +class ProjectWrite(ProjectCore): + """Projects are used to isolate data in CDF rom each other. All objects in CDF belong to a single project, + and objects in different projects are isolated from each other. + + This is the write format of a Project. It is used when creating a new Project. + + Typically, we would have at least one dev and a prod project. The dev project is used for development and testing, + while the prod project is used for production data. + + Args: + name (str): The user-friendly name of the project. + url_name (str): The URL name of the project. This is used as part of the request path in API calls. Valid URL names contain between 3 and 32 characters, and may only contain English letters, digits and hyphens, must contain at least one letter and may not start or end with a hyphen. + parent_project_url_name (str): The URL name of the project from which the new project is being created- this project must already exist. + admin_source_group_id (str | None): ID of the group in the source. If this is the same ID as a group in the IdP, a principal in that group will implicitly be a part of this group as well. + oidc_configuration (OIDCConfiguration | None): The OIDC configuration for the project. + user_profiles_configuration (UserProfilesConfiguration | None): Should the collection of user profiles be enabled for the project. + """ + + def __init__( + self, + name: str, + url_name: str, + parent_project_url_name: str, + admin_source_group_id: str | None = None, + oidc_configuration: OIDCConfiguration | None = None, + user_profiles_configuration: UserProfilesConfiguration | None = None, + ) -> None: + super().__init__(name, url_name, oidc_configuration) + self.parent_project_url_name = parent_project_url_name + self.admin_source_group_id = admin_source_group_id + # user_profile_configuration is not in ProjectCore as it + # is required for the Read format but not the Write format. + self.user_profiles_configuration = user_profiles_configuration + + def as_write(self) -> ProjectWrite: + """Returns this instance which is a Project Write""" + return self + + @classmethod + def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> ProjectWrite: + return cls( + name=resource["name"], + url_name=resource["urlName"], + parent_project_url_name=resource["parentProjectUrlName"], + admin_source_group_id=resource.get("adminSourceGroupId"), + oidc_configuration=OIDCConfiguration._load(resource["oidcConfiguration"]) + if "oidcConfiguration" in resource + else None, + user_profiles_configuration=UserProfilesConfiguration._load(resource["userProfilesConfiguration"]) + if "userProfilesConfiguration" in resource + else None, + ) + + def dump(self, camel_case: bool = True) -> dict[str, Any]: + output: dict[str, Any] = { + "name": self.name, + "urlName" if camel_case else "url_name": self.url_name, + "parentProjectUrlName" if camel_case else "parent_project_url_name": self.parent_project_url_name, + } + if self.admin_source_group_id is not None: + output["adminSourceGroupId" if camel_case else "admin_source_group_id"] = self.admin_source_group_id + if self.oidc_configuration is not None: + output["oidcConfiguration" if camel_case else "oidc_configuration"] = self.oidc_configuration.dump( + camel_case + ) + if self.user_profiles_configuration is not None: + output[ + "userProfilesConfiguration" if camel_case else "user_profiles_configuration" + ] = self.user_profiles_configuration.dump(camel_case) + return output + + +class Project(ProjectCore): + """Projects are used to isolate data in CDF rom each other. All objects in CDF belong to a single project, + and objects in different projects are isolated from each other. + + This is the read format of a Project. It is used when retrieving a Project. + + Typically, we would have at least one dev and a prod project. The dev project is used for development and testing, + while the prod project is used for production data. + + Args: + name (str): The user-friendly name of the project. + url_name (str): The URL name of the project. This is used as part of the request path in API calls. Valid URL names contain between 3 and 32 characters, and may only contain English letters, digits and hyphens, must contain at least one letter and may not start or end with a hyphen. + user_profiles_configuration (UserProfilesConfiguration): Should the collection of user profiles be enabled for the project. + oidc_configuration (OIDCConfiguration | None): The OIDC configuration for the project. + """ + + def __init__( + self, + name: str, + url_name: str, + user_profiles_configuration: UserProfilesConfiguration, + oidc_configuration: OIDCConfiguration | None = None, + ) -> None: + super().__init__(name, url_name, oidc_configuration) + # user_profile_configuration is not in ProjectCore as it + # is required for the Read format but not the Write format. + self.user_profiles_configuration = user_profiles_configuration + + def as_write(self) -> ProjectWrite: + """Returns this instance which is a Project Write""" + raise NotImplementedError("Project cannot be used as a ProjectWrite") + + @classmethod + def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> Project: + return cls( + name=resource["name"], + url_name=resource["urlName"], + user_profiles_configuration=UserProfilesConfiguration._load(resource["userProfilesConfiguration"]), + oidc_configuration=OIDCConfiguration._load(resource["oidcConfiguration"]) + if "oidcConfiguration" in resource + else None, + ) + + def dump(self, camel_case: bool = True) -> dict[str, Any]: + output: dict[str, Any] = { + "name": self.name, + "urlName" if camel_case else "url_name": self.url_name, + "userProfilesConfiguration" + if camel_case + else "user_profiles_configuration": self.user_profiles_configuration.dump(camel_case), + } + if self.oidc_configuration is not None: + output["oidcConfiguration" if camel_case else "oidc_configuration"] = self.oidc_configuration.dump( + camel_case + ) + return output + + +# Move into _base? +class _CogniteNestedUpdate(Generic[T_CogniteUpdate]): + def __init__(self, parent_object: T_CogniteUpdate, name: str) -> None: + self._parent_object = parent_object + self._name = name + + def _set(self, value: CogniteObject | None) -> T_CogniteUpdate: + if self._name not in self._parent_object._update_object: + self._parent_object._update_object[self._name] = {} + update_object = self._parent_object._update_object[self._name] + if "modify" in update_object: + raise RuntimeError("Cannot set and modify the same property") + if value is None: + update_object["setNull"] = True + else: + update_object["set"] = value.dump(camel_case=True) + return self._parent_object + + +class _CogniteNestedUpdateProperty(Generic[T_CogniteUpdate]): + def __init__(self, parent_object: T_CogniteUpdate, parent_name: str, name: str) -> None: + self._parent_object = parent_object + self._parent_name = parent_name + self._name = name + + @property + def _update_object(self) -> dict[str, Any]: + if self._parent_name not in self._parent_object._update_object: + self._parent_object._update_object[self._parent_name] = {} + update_object = self._parent_object._update_object[self._parent_name] + if "set" in update_object: + raise RuntimeError("Cannot set and modify the same property") + if "modify" not in update_object: + update_object["modify"] = {} + if self._name in update_object["modify"]: + raise RuntimeError(f"Cannot modify {self._name} twice") + return update_object + + +class _CogniteNestedPrimitiveUpdate(_CogniteNestedUpdateProperty[T_CogniteUpdate]): + def _set(self, value: None | str | int | bool) -> T_CogniteUpdate: + update_object = self._update_object + if self._parent_name == "userProfilesConfiguration" and self._name == "enabled": + # Bug in Spec? + update_object["modify"][self._name] = value + elif value is None: + update_object["modify"][self._name] = {"setNull": True} + else: + update_object["modify"][self._name] = {"set": value} + return self._parent_object + + +class _CogniteNestedListUpdate(_CogniteNestedUpdateProperty[T_CogniteUpdate]): + def _update_modify_object( + self, values: CogniteObject | Sequence[CogniteObject], word: Literal["set", "add", "remove"] + ) -> T_CogniteUpdate: + update_object = self._update_object + value_list = [values] if isinstance(values, CogniteObject) else values + if update_object["modify"].get(self._name) is not None: + raise RuntimeError(f"Cannot {word} and modify the same property twice") + update_object["modify"][self._name] = {word: [value.dump(camel_case=True) for value in value_list]} + return self._parent_object + + def _set(self, values: CogniteObject | Sequence[CogniteObject]) -> T_CogniteUpdate: + return self._update_modify_object(values, "set") + + def _add(self, values: CogniteObject | Sequence[CogniteObject]) -> T_CogniteUpdate: + return self._update_modify_object(values, "add") + + def _remove(self, values: CogniteObject | Sequence[CogniteObject]) -> T_CogniteUpdate: + return self._update_modify_object(values, "remove") + + +class ProjectUpdate(CogniteUpdate): + """Changes applied to a Project. + + Args: + project (str): The Project to be updated. + """ + + def __init__(self, project: str) -> None: + super().__init__(None, None) + self._project = project + + class _PrimitiveProjectUpdate(CognitePrimitiveUpdate["ProjectUpdate"]): + def set(self, value: str) -> ProjectUpdate: + return self._set(value) + + class _NestedPrimitiveUpdateNullable(_CogniteNestedPrimitiveUpdate["ProjectUpdate"]): + def set(self, value: str | bool | int | None) -> ProjectUpdate: + return self._set(value) + + class _NestedPrimitiveUpdate(_CogniteNestedPrimitiveUpdate["ProjectUpdate"]): + def set(self, value: str | bool | int) -> ProjectUpdate: + return self._set(value) + + class _NestedListUpdate(_CogniteNestedListUpdate["ProjectUpdate"]): + def set(self, values: Claim | Sequence[Claim]) -> ProjectUpdate: + return self._set(values) + + def add(self, values: Claim | Sequence[Claim]) -> ProjectUpdate: + return self._add(values) + + def remove(self, values: Claim | Sequence[Claim]) -> ProjectUpdate: + return self._remove(values) + + class _NestedOIDCConfiguration(_CogniteNestedUpdate["ProjectUpdate"]): + class _OIDCConfigurationUpdate: + def __init__(self, parent_object: ProjectUpdate, name: str) -> None: + self._parent_object = parent_object + self._name = name + + @property + def jwks_url(self) -> ProjectUpdate._NestedPrimitiveUpdate: + return ProjectUpdate._NestedPrimitiveUpdate(self._parent_object, self._name, "jwksUrl") + + @property + def token_url(self) -> ProjectUpdate._NestedPrimitiveUpdateNullable: + return ProjectUpdate._NestedPrimitiveUpdateNullable(self._parent_object, self._name, "tokenUrl") + + @property + def issuer(self) -> ProjectUpdate._NestedPrimitiveUpdate: + return ProjectUpdate._NestedPrimitiveUpdate(self._parent_object, self._name, "issuer") + + @property + def audience(self) -> ProjectUpdate._NestedPrimitiveUpdate: + return ProjectUpdate._NestedPrimitiveUpdate(self._parent_object, self._name, "audience") + + @property + def skew_ms(self) -> ProjectUpdate._NestedPrimitiveUpdateNullable: + return ProjectUpdate._NestedPrimitiveUpdateNullable(self._parent_object, self._name, "skewMs") + + @property + def access_claims(self) -> ProjectUpdate._NestedListUpdate: + return ProjectUpdate._NestedListUpdate(self._parent_object, self._name, "accessClaims") + + @property + def scope_claims(self) -> ProjectUpdate._NestedListUpdate: + return ProjectUpdate._NestedListUpdate(self._parent_object, self._name, "scopeClaims") + + @property + def log_claims(self) -> ProjectUpdate._NestedListUpdate: + return ProjectUpdate._NestedListUpdate(self._parent_object, self._name, "logClaims") + + @property + def is_group_callback_enabled(self) -> ProjectUpdate._NestedPrimitiveUpdateNullable: + return ProjectUpdate._NestedPrimitiveUpdateNullable( + self._parent_object, self._name, "isGroupCallbackEnabled" + ) + + @property + def identity_provider_scope(self) -> ProjectUpdate._NestedPrimitiveUpdateNullable: + return ProjectUpdate._NestedPrimitiveUpdateNullable( + self._parent_object, self._name, "identityProviderScope" + ) + + def set(self, value: OIDCConfiguration | None) -> ProjectUpdate: + return self._set(value) + + @property + def modify(self) -> _OIDCConfigurationUpdate: + return self._OIDCConfigurationUpdate(self._parent_object, self._name) + + class _NestedUserProfilesConfiguration(_CogniteNestedUpdate["ProjectUpdate"]): + class _UserProfilesConfigurationUpdate: + def __init__(self, parent_object: ProjectUpdate, name: str) -> None: + self._parent_object = parent_object + self._name = name + + @property + def enabled(self) -> ProjectUpdate._NestedPrimitiveUpdateNullable: + return ProjectUpdate._NestedPrimitiveUpdateNullable(self._parent_object, self._name, "enabled") + + def set(self, value: UserProfilesConfiguration) -> ProjectUpdate: + return self._set(value) + + @property + def modify(self) -> _UserProfilesConfigurationUpdate: + return self._UserProfilesConfigurationUpdate(self._parent_object, self._name) + + @property + def name(self) -> _PrimitiveProjectUpdate: + return ProjectUpdate._PrimitiveProjectUpdate(self, "name") + + @property + def oidc_configuration(self) -> _NestedOIDCConfiguration: + return ProjectUpdate._NestedOIDCConfiguration(self, "oidcConfiguration") + + @property + def user_profiles_configuration(self) -> _NestedUserProfilesConfiguration: + return ProjectUpdate._NestedUserProfilesConfiguration(self, "userProfilesConfiguration") + + @classmethod + def _get_update_properties(cls) -> list[PropertySpec]: + return [ + PropertySpec("name", is_nullable=False), + PropertySpec("oidc_configuration", is_nullable=True), + PropertySpec("user_profiles_configuration", is_nullable=False), + ] + + +class ProjectURLNameList(CogniteResourceList[ProjectURLName]): + _RESOURCE = ProjectURLName + + +class ProjectList(CogniteResourceList[Project]): + _RESOURCE = Project + + +class ProjectWriteList(CogniteResourceList[ProjectWrite]): + _RESOURCE = ProjectWrite diff --git a/cognite/client/testing.py b/cognite/client/testing.py index a939799f06..cdb969a5c3 100644 --- a/cognite/client/testing.py +++ b/cognite/client/testing.py @@ -31,6 +31,7 @@ from cognite.client._api.geospatial import GeospatialAPI from cognite.client._api.iam import IAMAPI, GroupsAPI, SecurityCategoriesAPI, SessionsAPI, TokenAPI from cognite.client._api.labels import LabelsAPI +from cognite.client._api.projects import ProjectsAPI from cognite.client._api.raw import RawAPI, RawDatabasesAPI, RawRowsAPI, RawTablesAPI from cognite.client._api.relationships import RelationshipsAPI from cognite.client._api.sequences import SequencesAPI, SequencesDataAPI @@ -112,6 +113,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.geospatial = MagicMock(spec_set=GeospatialAPI) self.iam = MagicMock(spec=IAMAPI) + self.iam.projects = MagicMock(spec_set=ProjectsAPI) self.iam.groups = MagicMock(spec_set=GroupsAPI) self.iam.security_categories = MagicMock(spec_set=SecurityCategoriesAPI) self.iam.sessions = MagicMock(spec_set=SessionsAPI) diff --git a/docs/source/identity_and_access_management.rst b/docs/source/identity_and_access_management.rst index ec0686518c..415ffc798e 100644 --- a/docs/source/identity_and_access_management.rst +++ b/docs/source/identity_and_access_management.rst @@ -16,6 +16,25 @@ Inspect the token currently used by the client ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. automethod:: cognite.client._api.iam.TokenAPI.inspect +Projects +^^^^^^^^^ +Create projects +~~~~~~~~~~~~~~~~ +.. automethod:: cognite.client._api.projects.ProjectsAPI.create + +List projects +~~~~~~~~~~~~~~ +.. automethod:: cognite.client._api.projects.ProjectsAPI.list + +Retrieve project +~~~~~~~~~~~~~~~~~ +.. automethod:: cognite.client._api.projects.ProjectsAPI.retrieve + +Update project +~~~~~~~~~~~~~~~~ +.. automethod:: cognite.client._api.projects.ProjectsAPI.update + + Groups ^^^^^^ List groups @@ -94,6 +113,10 @@ Data classes :members: :show-inheritance: +.. automodule:: cognite.client.data_classes.projects + :members: + :show-inheritance: + .. automodule:: cognite.client.data_classes.user_profiles :members: :show-inheritance: diff --git a/tests/tests_integration/test_api/test_projects.py b/tests/tests_integration/test_api/test_projects.py new file mode 100644 index 0000000000..e13f5c6bbf --- /dev/null +++ b/tests/tests_integration/test_api/test_projects.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import pytest + +from cognite.client.data_classes import ProjectURLName, ProjectURLNameList + + +@pytest.fixture(scope="module") +def available_projects(cognite_client) -> ProjectURLNameList: + if projects := cognite_client.iam.projects.list(): + return projects + pytest.skip("Can't test projects without any projects available", allow_module_level=True) + + +@pytest.mark.skip(reason="Lack access to projects to perform operations") +class TestProjects: + def test_list_projects(self, available_projects: ProjectURLNameList) -> None: + assert len(available_projects) >= 1, "Expected at least one project" + assert isinstance(available_projects, ProjectURLNameList) + assert isinstance(available_projects[0], ProjectURLName) diff --git a/tests/tests_unit/test_base.py b/tests/tests_unit/test_base.py index 8b7c6b179f..5d0ad42c5d 100644 --- a/tests/tests_unit/test_base.py +++ b/tests/tests_unit/test_base.py @@ -12,7 +12,7 @@ from cognite.client import ClientConfig, CogniteClient from cognite.client.credentials import Token -from cognite.client.data_classes import Feature, FeatureAggregate +from cognite.client.data_classes import Feature, FeatureAggregate, Project from cognite.client.data_classes._base import ( CogniteFilter, CogniteLabelUpdate, @@ -194,6 +194,8 @@ def test_dump_load_only_required( [ pytest.param(class_, id=f"{class_.__name__} in {class_.__module__}") for class_ in all_concrete_subclasses(WriteableCogniteResource) + # Project does not support as_write + if class_ is not Project ], ) def test_writable_as_write( diff --git a/tests/tests_unit/test_data_classes/test_projects.py b/tests/tests_unit/test_data_classes/test_projects.py new file mode 100644 index 0000000000..cb5f0ad777 --- /dev/null +++ b/tests/tests_unit/test_data_classes/test_projects.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import Any, Iterable + +import pytest +from _pytest.mark import ParameterSet + +from cognite.client.data_classes import Claim, ProjectUpdate, UserProfilesConfiguration + + +def project_update_dump_test_cases() -> Iterable[ParameterSet]: + update = ProjectUpdate("my_project").name.set("new_name") + yield pytest.param( + update, + {"update": {"name": {"set": "new_name"}}}, + id="Set name", + ) + update = ProjectUpdate("my_project").user_profiles_configuration.modify.enabled.set(False).name.set("new_name") + + yield pytest.param( + update, + {"update": {"userProfilesConfiguration": {"modify": {"enabled": False}}, "name": {"set": "new_name"}}}, + id="Modify user profiles configuration", + ) + update = ProjectUpdate("my_project").user_profiles_configuration.set(UserProfilesConfiguration(enabled=True)) + + yield pytest.param( + update, + {"update": {"userProfilesConfiguration": {"set": {"enabled": True}}}}, + id="Set user profiles configuration", + ) + + update = ProjectUpdate("my_project").oidc_configuration.set(None).name.set("new_name") + yield pytest.param( + update, + {"update": {"oidcConfiguration": {"setNull": True}, "name": {"set": "new_name"}}}, + id="Set oidc configuration and name", + ) + + update = ( + ProjectUpdate("my_project") + .oidc_configuration.modify.jwks_url.set("new_url") + .oidc_configuration.modify.skew_ms.set(None) + ) + + yield pytest.param( + update, + {"update": {"oidcConfiguration": {"modify": {"jwksUrl": {"set": "new_url"}, "skewMs": {"setNull": True}}}}}, + id="Modify oidc configuration", + ) + + update = ( + ProjectUpdate("my_project").oidc_configuration.modify.access_claims.add(Claim("new_claim")).name.set("new_name") + ) + + yield pytest.param( + update, + { + "update": { + "oidcConfiguration": {"modify": {"accessClaims": {"add": [{"claimName": "new_claim"}]}}}, + "name": {"set": "new_name"}, + } + }, + id="Modify oidc configuration", + ) + + +class TestProjectUpdate: + @pytest.mark.parametrize("project_update, expected_dump", list(project_update_dump_test_cases())) + def test_dump(self, project_update: ProjectUpdate, expected_dump: dict[str, Any]) -> None: + assert project_update.dump() == expected_dump