diff --git a/neptune/management/exceptions.py b/neptune/management/exceptions.py index 595fc4a1f..79f94413f 100644 --- a/neptune/management/exceptions.py +++ b/neptune/management/exceptions.py @@ -96,6 +96,11 @@ class ProjectsLimitReached(ManagementOperationFailure): description = 'Project number limit reached.' +class UnsupportedValue(ManagementOperationFailure): + code = 12 + description = '{enum} cannot have value {value}' + + class BadRequestException(ManagementOperationFailure): code = 400 description = 'Your request has encountered following validation errors: {validation_errors}' diff --git a/neptune/management/internal/api.py b/neptune/management/internal/api.py index d2a59236e..1263f8141 100644 --- a/neptune/management/internal/api.py +++ b/neptune/management/internal/api.py @@ -43,6 +43,7 @@ BadRequestException, ProjectsLimitReached, ) +from neptune.management.internal.dto import ProjectVisibilityDTO, ProjectMemberRoleDTO, WorkspaceMemberRoleDTO def _get_token(api_token: Optional[str] = None) -> str: @@ -64,6 +65,22 @@ def _get_backend_client(api_token: Optional[str] = None) -> SwaggerClient: @with_api_exceptions_handler def get_project_list(api_token: Optional[str] = None) -> List[str]: + """Get a list of projects you have access to. + Args: + api_token(str, optional): User’s API token. Defaults to `None`. + If `None`, the value of `NEPTUNE_API_TOKEN` environment variable will be taken. + .. note:: + It is strongly recommended to use `NEPTUNE_API_TOKEN` environment variable rather than placing your + API token in plain text in your source code. + Returns: + ``List[str]``: list of project names of a format 'WORKSPACE/PROJECT' + Examples: + >>> from neptune import management + >>> management.get_project_list() + You may also want to check `management API reference`_. + .. _management API reference: + https://docs.neptune.ai/api-reference/management + """ verify_type('api_token', api_token, (str, type(None))) backend_client = _get_backend_client(api_token=api_token) @@ -84,14 +101,46 @@ def create_project( name: str, key: str, workspace: Optional[str] = None, - visibility: ProjectVisibility = ProjectVisibility.PRIVATE, + visibility: str = ProjectVisibility.PRIVATE, description: Optional[str] = None, api_token: Optional[str] = None ) -> str: + """Creates a new project in your Neptune workspace. + Args: + name(str): The name of the project in Neptune in the format 'WORKSPACE/PROJECT'. + If workspace argument was set, it should only contain 'PROJECT' instead of 'WORKSPACE/PROJECT'. + key(str): Project identifier. It has to be contain 1-10 upper case letters or numbers. + For example, 'GOOD5' + workspace(str, optional): Name of your Neptune workspace. + If you specify it, change the format of the name argument to 'PROJECT' instead of 'WORKSPACE/PROJECT'. + If 'None' it will be parsed from the `name` argument. + visibility(str, optional): level of visibility you want your project to have. + Can be set to: + - 'pub' for public projects + - 'priv' for private projects + If 'None' it will be set to 'priv' + description(str, optional): Project description. + If 'None', it will be left empty. + api_token(str, optional): User’s API token. Defaults to `None`. + If `None`, the value of `NEPTUNE_API_TOKEN` environment variable will be taken. + .. note:: + It is strongly recommended to use `NEPTUNE_API_TOKEN` environment variable rather than placing your + API token in plain text in your source code. + Returns: + ``str``: name of the new project you created. + Examples: + >>> from neptune import management + >>> management.create_project(name="awesome-team/amazing-project", + ... key="AMA", + ... visibility="pub") + You may also want to check `management API reference`_. + .. _management API reference: + https://docs.neptune.ai/api-reference/management + """ verify_type('name', name, str) verify_type('key', key, str) verify_type('workspace', workspace, (str, type(None))) - verify_type('visibility', visibility, ProjectVisibility) + verify_type('visibility', visibility, str) verify_type('description', description, (str, type(None))) verify_type('api_token', api_token, (str, type(None))) @@ -116,7 +165,7 @@ def create_project( 'description': description, 'projectKey': key, 'organizationId': workspace_name_to_id[workspace], - 'visibility': visibility.value + 'visibility': ProjectVisibilityDTO.from_str(visibility).value }, **DEFAULT_REQUEST_KWARGS } @@ -135,6 +184,25 @@ def create_project( @with_api_exceptions_handler def delete_project(name: str, workspace: Optional[str] = None, api_token: Optional[str] = None): + """Deletes a project from your Neptune workspace. + Args: + name(str): The name of the project in Neptune in the format 'WORKSPACE/PROJECT'. + If workspace argument was set, it should only contain 'PROJECT' instead of 'WORKSPACE/PROJECT'. + workspace(str, optional): Name of your Neptune workspace. + If you specify it, change the format of the name argument to 'PROJECT' instead of 'WORKSPACE/PROJECT'. + If 'None' it will be parsed from the name argument. + api_token(str, optional): User’s API token. Defaults to `None`. + If `None`, the value of `NEPTUNE_API_TOKEN` environment variable will be taken. + .. note:: + It is strongly recommended to use `NEPTUNE_API_TOKEN` environment variable rather than placing your + API token in plain text in your source code. + Examples: + >>> from neptune import management + >>> management.delete_project(name="awesome-team/amazing-project") + You may also want to check `management API reference`_. + .. _management API reference: + https://docs.neptune.ai/api-reference/management + """ verify_type('name', name, str) verify_type('workspace', workspace, (str, type(None))) verify_type('api_token', api_token, (str, type(None))) @@ -159,13 +227,43 @@ def delete_project(name: str, workspace: Optional[str] = None, api_token: Option def add_project_member( name: str, username: str, - role: MemberRole, + role: str, workspace: Optional[str] = None, api_token: Optional[str] = None ): + """Adds member to the Neptune project. + Args: + name(str): The name of the project in Neptune in the format 'WORKSPACE/PROJECT'. + If workspace argument was set, it should only contain 'PROJECT' instead of 'WORKSPACE/PROJECT'. + username(str): Name of the user you want to add to the project. + role(str): level of permissions the user should have in a project. + Can be set to: + - 'viewer': can only view project content and members + - 'contributor': can view and edit project content and only view members + - 'owner': can view and edit project content and members + For more information, see `user roles in a project docs`_. + workspace(str, optional): Name of your Neptune workspace. + If you specify it, change the format of the name argument to 'PROJECT' instead of 'WORKSPACE/PROJECT'. + If 'None' it will be parsed from the name argument. + api_token(str, optional): User’s API token. Defaults to `None`. + If `None`, the value of `NEPTUNE_API_TOKEN` environment variable will be taken. + .. note:: + It is strongly recommended to use `NEPTUNE_API_TOKEN` environment variable rather than placing your + API token in plain text in your source code. + Examples: + >>> from neptune import management + >>> management.add_project_member(name="awesome-team/amazing-project", + ... username="johny", + ... role="contributor") + You may also want to check `management API reference`_. + .. _management API reference: + https://docs.neptune.ai/api-reference/management + .. _user roles in a project docs: + https://docs.neptune.ai/administration/user-management#roles-in-a-project + """ verify_type('name', name, str) verify_type('username', username, str) - verify_type('role', role, MemberRole) + verify_type('role', role, str) verify_type('workspace', workspace, (str, type(None))) verify_type('api_token', api_token, (str, type(None))) @@ -176,7 +274,7 @@ def add_project_member( 'projectIdentifier': project_identifier, 'member': { 'userId': username, - 'role': role.value + 'role': ProjectMemberRoleDTO.from_str(role).value }, **DEFAULT_REQUEST_KWARGS } @@ -195,6 +293,28 @@ def get_project_member_list( workspace: Optional[str] = None, api_token: Optional[str] = None ) -> Dict[str, str]: + """Get a list of members for a project. + Args: + name(str): The name of the project in Neptune in the format 'WORKSPACE/PROJECT'. + If workspace argument was set it should only contain 'PROJECT' instead of 'WORKSPACE/PROJECT'. + workspace(str, optional): Name of your Neptune workspace. + If you specify change the format of the name argument to 'PROJECT' instead of 'WORKSPACE/PROJECT'. + If 'None' it will be parsed from the name argument. + api_token(str, optional): User’s API token. Defaults to `None`. + If `None`, the value of `NEPTUNE_API_TOKEN` environment variable will be taken. + .. note:: + It is strongly recommended to use `NEPTUNE_API_TOKEN` environment variable rather than placing your + API token in plain text in your source code. + Returns: + ``Dict[str, str]``: Dictionary with usernames as keys and ProjectMemberRoles + ('owner', 'contributor', 'viewer') as values. + Examples: + >>> from neptune import management + >>> management.get_project_member_list(name="awesome-team/amazing-project") + You may also want to check `management API reference`_. + .. _management API reference: + https://docs.neptune.ai/api-reference/management + """ verify_type('name', name, str) verify_type('workspace', workspace, (str, type(None))) verify_type('api_token', api_token, (str, type(None))) @@ -209,7 +329,7 @@ def get_project_member_list( try: result = backend_client.api.listProjectMembers(**params).response().result - return {f'{m.registeredMemberInfo.username}': m.role for m in result} + return {f'{m.registeredMemberInfo.username}': ProjectMemberRoleDTO.to_domain(m.role) for m in result} except HTTPNotFound as e: raise ProjectNotFound(name=project_identifier) from e @@ -221,6 +341,27 @@ def remove_project_member( workspace: Optional[str] = None, api_token: Optional[str] = None ): + """Removes member from the Neptune project. + Args: + name(str): The name of the project in Neptune in the format 'WORKSPACE/PROJECT'. + If workspace argument was set, it should only contain 'PROJECT' instead of 'WORKSPACE/PROJECT'. + username(str): name of the user you want to remove from the project. + workspace(str, optional): Name of your Neptune workspace. + If you specify change the format of the name argument to 'PROJECT' instead of 'WORKSPACE/PROJECT'. + If 'None' it will be parsed from the name argument. + api_token(str, optional): User’s API token. Defaults to `None`. + If `None`, the value of `NEPTUNE_API_TOKEN` environment variable will be taken. + .. note:: + It is strongly recommended to use `NEPTUNE_API_TOKEN` environment variable rather than placing your + API token in plain text in your source code. + Examples: + >>> from neptune import management + >>> management.remove_project_member(name="awesome-team/amazing-project", + ... username="johny") + You may also want to check `management API reference`_. + .. _management API reference: + https://docs.neptune.ai/api-reference/management + """ verify_type('name', name, str) verify_type('username', username, str) verify_type('workspace', workspace, (str, type(None))) @@ -247,6 +388,23 @@ def remove_project_member( @with_api_exceptions_handler def get_workspace_member_list(name: str, api_token: Optional[str] = None) -> Dict[str, str]: + """Get a list of members of a workspace. + Args: + name(str, optional): Name of your Neptune workspace. + api_token(str, optional): User’s API token. Defaults to `None`. + If `None`, the value of `NEPTUNE_API_TOKEN` environment variable will be taken. + .. note:: + It is strongly recommended to use `NEPTUNE_API_TOKEN` environment variable rather than placing your + API token in plain text in your source code. + Returns: + ``Dict[str, str]``: Dictionary with usernames as keys and `WorkspaceMemberRole` ('member', 'admin') as values. + Examples: + >>> from neptune import management + >>> management.get_workspace_member_list(name="awesome-team") + You may also want to check `management API reference`_. + .. _management API reference: + https://docs.neptune.ai/api-reference/management + """ verify_type('name', name, str) verify_type('api_token', api_token, (str, type(None))) @@ -259,6 +417,6 @@ def get_workspace_member_list(name: str, api_token: Optional[str] = None) -> Dic try: result = backend_client.api.listOrganizationMembers(**params).response().result - return {f'{m.registeredMemberInfo.username}': m.role for m in result} + return {f'{m.registeredMemberInfo.username}': WorkspaceMemberRoleDTO.to_domain(m.role) for m in result} except HTTPNotFound as e: raise WorkspaceNotFound(workspace=name) from e diff --git a/neptune/management/internal/dto.py b/neptune/management/internal/dto.py new file mode 100644 index 000000000..2fd2960eb --- /dev/null +++ b/neptune/management/internal/dto.py @@ -0,0 +1,79 @@ +# +# Copyright (c) 2021, Neptune Labs Sp. z o.o. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from enum import Enum + +from neptune.new.internal.utils import verify_type + +from neptune.management.exceptions import UnsupportedValue +from neptune.management.internal.types import ProjectVisibility, ProjectMemberRole, WorkspaceMemberRole + + +class ProjectVisibilityDTO(Enum): + PRIVATE = 'priv' + PUBLIC = 'pub' + + @classmethod + def from_str(cls, visibility: str) -> 'ProjectVisibilityDTO': + verify_type('visibility', visibility, str) + + try: + return { + ProjectVisibility.PRIVATE: ProjectVisibilityDTO.PRIVATE, + ProjectVisibility.PUBLIC: ProjectVisibilityDTO.PUBLIC + }[visibility] + except KeyError as e: + raise UnsupportedValue(enum=cls.__name__, value=visibility) from e + + +class ProjectMemberRoleDTO(Enum): + VIEWER = 'viewer' + MEMBER = 'member' + MANAGER = 'manager' + + @classmethod + def from_str(cls, role: str) -> 'ProjectMemberRoleDTO': + verify_type('role', role, str) + + try: + return { + ProjectMemberRole.VIEWER: ProjectMemberRoleDTO.VIEWER, + ProjectMemberRole.CONTRIBUTOR: ProjectMemberRoleDTO.MEMBER, + ProjectMemberRole.OWNER: ProjectMemberRoleDTO.MANAGER + }[role] + except KeyError as e: + raise UnsupportedValue(enum=cls.__name__, value=role) from e + + @staticmethod + def to_domain(role: str) -> str: + verify_type('role', role, str) + + return { + ProjectMemberRoleDTO.VIEWER.value: ProjectMemberRole.VIEWER, + ProjectMemberRoleDTO.MANAGER.value: ProjectMemberRole.OWNER, + ProjectMemberRoleDTO.MEMBER.value: ProjectMemberRole.CONTRIBUTOR + }.get(role) + + +class WorkspaceMemberRoleDTO(Enum): + OWNER = 'owner' + MEMBER = 'member' + + @staticmethod + def to_domain(role: str) -> str: + return { + WorkspaceMemberRoleDTO.OWNER.value: WorkspaceMemberRole.ADMIN, + WorkspaceMemberRoleDTO.MEMBER.value: WorkspaceMemberRole.MEMBER + }.get(role) diff --git a/neptune/management/internal/types.py b/neptune/management/internal/types.py index 6514d394a..360a93ed9 100644 --- a/neptune/management/internal/types.py +++ b/neptune/management/internal/types.py @@ -13,15 +13,24 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from enum import Enum - - -class ProjectVisibility(Enum): +class ProjectVisibility: PRIVATE = 'priv' PUBLIC = 'pub' -class MemberRole(Enum): +class ProjectMemberRole: VIEWER = 'viewer' + OWNER = 'owner' + CONTRIBUTOR = 'contributor' + + # Deprecated + MEMBER = CONTRIBUTOR + MANAGER = OWNER + + +MemberRole = ProjectMemberRole + + +class WorkspaceMemberRole: + ADMIN = 'admin' MEMBER = 'member' - MANAGER = 'manager' diff --git a/tests/neptune/new/internal/backends/test_hosted_client.py b/tests/neptune/new/internal/backends/test_hosted_client.py index 55176b5fd..8d3dbf238 100644 --- a/tests/neptune/new/internal/backends/test_hosted_client.py +++ b/tests/neptune/new/internal/backends/test_hosted_client.py @@ -38,6 +38,7 @@ UserNotExistsOrWithoutAccess, UserAlreadyHasAccess, AccessRevokedOnMemberRemoval, + UnsupportedValue, ) from neptune.new.internal.backends.utils import verify_host_resolution from neptune.new.internal.backends.hosted_client import ( @@ -127,7 +128,7 @@ def test_workspace_members(self, swagger_client_factory): ) ), Mock( - role='admin', + role='owner', registeredMemberInfo=Mock( username='tester2' ) @@ -180,10 +181,16 @@ def test_project_members(self, swagger_client_factory): ) ), Mock( - role='admin', + role='manager', registeredMemberInfo=Mock( username='tester2' ) + ), + Mock( + role='viewer', + registeredMemberInfo=Mock( + username='tester3' + ) ) ] swagger_client.api.listProjectMembers.return_value.response = BravadoResponseMock( @@ -194,7 +201,7 @@ def test_project_members(self, swagger_client_factory): returned_members = get_project_member_list(name='org/proj', api_token=API_TOKEN) # then: - self.assertEqual({'tester1': 'member', 'tester2': 'admin'}, returned_members) + self.assertEqual({'tester1': 'contributor', 'tester2': 'owner', 'tester3': 'viewer'}, returned_members) def test_project_members_empty(self, swagger_client_factory): swagger_client = self._get_swagger_client_mock(swagger_client_factory) @@ -273,6 +280,26 @@ def test_create_project_already_exists(self, swagger_client_factory): with self.assertRaises(ProjectAlreadyExists): create_project(name='org/proj', key='PRJ', api_token=API_TOKEN) + def test_create_project_unknown_visibility(self, swagger_client_factory): + swagger_client = self._get_swagger_client_mock(swagger_client_factory) + + # given: + organization = Mock( + id=str(uuid.uuid4()) + ) + organization.name = 'org' + organizations = [ + organization + ] + + # when: + swagger_client.api.listOrganizations.return_value.response = BravadoResponseMock( + result=organizations, + ) + + with self.assertRaises(UnsupportedValue): + create_project(name='org/proj', key='PRJ', visibility="unknown_value", api_token=API_TOKEN) + def test_create_project_no_workspace(self, swagger_client_factory): swagger_client = self._get_swagger_client_mock(swagger_client_factory) @@ -333,6 +360,13 @@ def test_add_project_member_project_not_found(self, swagger_client_factory): with self.assertRaises(ProjectNotFound): add_project_member(name='org/proj', username='tester', role=MemberRole.VIEWER, api_token=API_TOKEN) + def test_add_project_member_unknown_role(self, swagger_client_factory): + _ = self._get_swagger_client_mock(swagger_client_factory) + + # then: + with self.assertRaises(UnsupportedValue): + add_project_member(name='org/proj', username='tester', role='unknown_role', api_token=API_TOKEN) + def test_add_project_member_member_without_access(self, swagger_client_factory): swagger_client = self._get_swagger_client_mock(swagger_client_factory)