From 4c7245e9f3fd74faea202497c096d65a3433e219 Mon Sep 17 00:00:00 2001 From: tdstein Date: Tue, 2 Apr 2024 10:22:38 -0400 Subject: [PATCH 1/4] feat: adds permissions find, find_one, and update --- src/posit/connect/content.py | 20 +-- src/posit/connect/permissions.py | 106 +++++++++++++ src/posit/connect/resources.py | 32 +--- src/posit/connect/users.py | 2 +- tests/posit/connect/test_permissions.py | 192 ++++++++++++++++++++++++ tests/posit/connect/test_resources.py | 51 ------- 6 files changed, 310 insertions(+), 93 deletions(-) create mode 100644 src/posit/connect/permissions.py create mode 100644 tests/posit/connect/test_permissions.py diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 16d132e3..4504be35 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -10,22 +10,12 @@ from . import urls from .config import Config +from .permissions import Permissions from .resources import Resources, Resource class ContentItem(Resource): - """A piece of content. - - Parameters - ---------- - Resource : _type_ - _description_ - - Returns - ------- - _type_ - _description_ - """ + """A piece of content.""" @property def guid(self) -> str: @@ -199,6 +189,10 @@ def app_role(self) -> str: def id(self) -> str: return self.get("id") # type: ignore + @property + def permissions(self) -> Permissions: + return Permissions(self.config, self.session, content_guid=self.guid) + @overload def update( self, @@ -297,7 +291,7 @@ def update(self, *args, **kwargs) -> None: super().update(**response.json()) -class Content(Resources[ContentItem]): +class Content(Resources): def __init__(self, config: Config, session: Session) -> None: self.url = urls.append_path(config.url, "v1/content") self.config = config diff --git a/src/posit/connect/permissions.py b/src/posit/connect/permissions.py new file mode 100644 index 00000000..077525dd --- /dev/null +++ b/src/posit/connect/permissions.py @@ -0,0 +1,106 @@ +from typing import List, overload + +from requests.sessions import Session as Session + +from posit.connect.config import Config + +from . import urls + +from .resources import Resource, Resources + + +class Permission(Resource): + @property + def guid(self) -> str: + """The globally unique identifier. + + An alias for the field 'id'. + + The conventional name for this field is 'guid'. As of v2024.03.0, the API uses the field name 'id'. + + Returns + ------- + str + """ + # Alias 'id' to 'guid'. + # The conventional name for this field across applications is 'guid'. + return self.get("id", self.get("guid")) # type: ignore + + @property + def content_guid(self) -> str: + return self.get("content_guid") # type: ignore + + @property + def principal_guid(self) -> str: + return self.get("principal_guid") # type: ignore + + @property + def principal_type(self) -> str: + return self.get("principal_type") # type: ignore + + @property + def role(self) -> str: + return self.get("role") # type: ignore + + @overload + def update(self, role: str) -> None: + """Update a permission. + + Parameters + ---------- + role : str + The principal role. + """ + ... + + @overload + def update(self, *args, **kwargs) -> None: + """Update a permission.""" + ... + + def update(self, *args, **kwargs) -> None: + """Update a permission.""" + body = dict(*args, **kwargs) + path = f"v1/content/{self.content_guid}/permissions/{self.guid}" + url = urls.append_path(self.config.url, path) + response = self.session.put( + url, + json={ + "principal_guid": self.principal_guid, + "principal_type": self.principal_type, + "role": self.role, + # shorthand to overwrite the above fields with method arguments + **body, + }, + ) + super().update(**response.json()) + + +class Permissions(Resources): + def __init__(self, config: Config, session: Session, *, content_guid: str) -> None: + super().__init__(config, session) + self.content_guid = content_guid + + def find(self, *args, **kwargs) -> List[Permission]: + """Find permissions. + + Returns + ------- + List[Permission] + """ + body = dict(*args, **kwargs) + path = f"v1/content/{self.content_guid}/permissions" + url = urls.append_path(self.config.url, path) + response = self.session.get(url, json=body) + results = response.json() + return [Permission(self.config, self.session, **result) for result in results] + + def find_one(self, *args, **kwargs) -> Permission | None: + """Find a permission. + + Returns + ------- + Permission | None + """ + permissions = self.find(*args, **kwargs) + return next(iter(permissions), None) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index b5e77eaf..176828ef 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -44,31 +44,7 @@ def __setattr__(self, name: str, value: Any) -> None: raise AttributeError("cannot set attributes: use update() instead") -class Resources(ABC, Generic[T]): - @abstractmethod - def create(self, *args, **kwargs) -> T: - raise NotImplementedError() - - @abstractmethod - def delete(self, *args, **kwargs) -> None: - raise NotImplementedError() - - @abstractmethod - def find(self, *args, **kwargs) -> List[T]: - raise NotImplementedError() - - @abstractmethod - def find_one(self, *args, **kwargs) -> Optional[T]: - raise NotImplementedError() - - @abstractmethod - def get(self, *args, **kwargs) -> T: - raise NotImplementedError() - - @abstractmethod - def update(self, *args, **kwargs) -> T: - raise NotImplementedError() - - @abstractmethod - def count(self, *args, **kwargs) -> int: - raise NotImplementedError() +class Resources(ABC): + def __init__(self, config: Config, session: requests.Session) -> None: + self.config = config + self.session = session diff --git a/src/posit/connect/users.py b/src/posit/connect/users.py index a35ce502..f5598c1d 100644 --- a/src/posit/connect/users.py +++ b/src/posit/connect/users.py @@ -151,7 +151,7 @@ def update(self, *args, **kwargs) -> None: super().update(**response.json()) -class Users(Resources[User]): +class Users(Resources): def __init__(self, config: Config, session: requests.Session) -> None: self.url = urls.append_path(config.url, "v1/users") self.config = config diff --git a/tests/posit/connect/test_permissions.py b/tests/posit/connect/test_permissions.py new file mode 100644 index 00000000..eff051b7 --- /dev/null +++ b/tests/posit/connect/test_permissions.py @@ -0,0 +1,192 @@ +import uuid + +from unittest.mock import Mock + +import requests +import responses + +from responses import matchers + +from posit.connect.config import Config +from posit.connect.permissions import Permission, Permissions + + +class TestPermissionGuid: + def test_from_id(self): + config = Mock() + session = Mock() + guid = str(uuid.uuid4()) + permission = Permission(config, session, guid=guid) + assert permission.guid == guid + + def test_from_guid(self): + config = Mock() + session = Mock() + guid = str(uuid.uuid4()) + permission = Permission(config, session, id=guid) + assert permission.guid == guid + + +class TestPermissionUpdate: + @responses.activate + def test_request_shape(self): + # test data + guid = str(uuid.uuid4()) + content_guid = str(uuid.uuid4()) + principal_guid = str(uuid.uuid4()) + principal_type = "principal_type" + role = "role" + extraneous = "extraneous" + + # define api behavior + responses.put( + f"https://connect.example/__api__/v1/content/{content_guid}/permissions/{guid}", + json={ + # doesn't matter for this test + }, + match=[ + # assertion + matchers.json_params_matcher( + { + # validate that initial permission fields are set + "principal_guid": principal_guid, + "principal_type": principal_type, + "role": role, + # validate that arguments passed to update are set + "extraneous": extraneous, + } + ) + ], + ) + + # setup + config = Config(api_key="12345", url="https://connect.example/") + session = requests.Session() + permission = Permission( + config, + session, + guid=guid, + content_guid=content_guid, + principal_guid=principal_guid, + principal_type=principal_type, + role=role, + ) + + # invoke + # assertion occurs in match above + permission.update(extraneous=extraneous) + + @responses.activate + def test_role_update(self): + # test data + old_role = "old_role" + new_role = "new_role" + + # define api behavior + guid = str(uuid.uuid4()) + content_guid = str(uuid.uuid4()) + responses.put( + f"https://connect.example/__api__/v1/content/{content_guid}/permissions/{guid}", + json={"role": new_role}, + match=[ + matchers.json_params_matcher( + { + "principal_guid": None, + "principal_type": None, + "role": new_role, + } + ) + ], + ) + + # setup + config = Config(api_key="12345", url="https://connect.example/") + session = requests.Session() + permission = Permission( + config, session, guid=guid, content_guid=content_guid, role=old_role + ) + + # assert role change with respect to api response + assert permission.role == old_role + permission.update(role=new_role) + assert permission.role == new_role + + +class TestPermissionsFind: + @responses.activate + def test(self): + # test data + content_guid = str(uuid.uuid4()) + fake_permissions = [ + { + "guid": str(uuid.uuid4()), + "content_guid": content_guid, + "principal_guid": str(uuid.uuid4()), + "principal_type": "user", + "role": "read", + }, + { + "guid": str(uuid.uuid4()), + "content_guid": content_guid, + "principal_guid": str(uuid.uuid4()), + "principal_type": "group", + "role": "write", + }, + ] + + # define api behavior + responses.get( + f"https://connect.example/__api__/v1/content/{content_guid}/permissions", + json=fake_permissions, + ) + + # setup + config = Config(api_key="12345", url="https://connect.example/") + session = requests.Session() + permissions = Permissions(config, session, content_guid=content_guid) + + # invoke + permissions = permissions.find() + + # assert response + assert permissions == fake_permissions + + +class TestPermissionsFindOne: + @responses.activate + def test(self): + # test data + content_guid = str(uuid.uuid4()) + fake_permissions = [ + { + "guid": str(uuid.uuid4()), + "content_guid": content_guid, + "principal_guid": str(uuid.uuid4()), + "principal_type": "user", + "role": "read", + }, + { + "guid": str(uuid.uuid4()), + "content_guid": content_guid, + "principal_guid": str(uuid.uuid4()), + "principal_type": "group", + "role": "write", + }, + ] + + # define api behavior + responses.get( + f"https://connect.example/__api__/v1/content/{content_guid}/permissions", + json=fake_permissions, + ) + + # setup + config = Config(api_key="12345", url="https://connect.example/") + session = requests.Session() + permissions = Permissions(config, session, content_guid=content_guid) + + # invoke + permission = permissions.find_one() + + # assert response + assert permission == fake_permissions[0] diff --git a/tests/posit/connect/test_resources.py b/tests/posit/connect/test_resources.py index 92cec237..fcfff3de 100644 --- a/tests/posit/connect/test_resources.py +++ b/tests/posit/connect/test_resources.py @@ -64,54 +64,3 @@ def test_foo(self): d = dict({k: v}) r = FakeResource(config, session, **d) assert r.foo == v - - -class TestResources(Resources[Any]): - def create(self) -> Any: - return super().create() # type: ignore [safe-super] - - def delete(self) -> None: - return super().delete() # type: ignore [safe-super] - - def find(self) -> List[Any]: - return super().find() # type: ignore [safe-super] - - def find_one(self) -> Optional[Any]: - return super().find_one() # type: ignore [safe-super] - - def get(self) -> Any: - return super().get() # type: ignore [safe-super] - - def update(self) -> Any: - return super().update() # type: ignore [safe-super] - - def count(self) -> int: - return super().count() # type: ignore [safe-super] - - def test_create(self): - with pytest.raises(NotImplementedError): - self.create() - - def test_delete(self): - with pytest.raises(NotImplementedError): - self.delete() - - def test_find(self): - with pytest.raises(NotImplementedError): - self.find() - - def test_find_one(self): - with pytest.raises(NotImplementedError): - self.find_one() - - def test_get(self): - with pytest.raises(NotImplementedError): - self.get() - - def test_update(self): - with pytest.raises(NotImplementedError): - self.update() - - def test_count(self): - with pytest.raises(NotImplementedError): - self.count() From cb0d320e2274c20421c6b4f0809c898e82bff2ff Mon Sep 17 00:00:00 2001 From: tdstein Date: Tue, 2 Apr 2024 10:27:15 -0400 Subject: [PATCH 2/4] fix: future annotations for py3.8 and py3.9 --- src/posit/connect/permissions.py | 5 +++-- src/posit/connect/users.py | 12 ++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/posit/connect/permissions.py b/src/posit/connect/permissions.py index 077525dd..d472d284 100644 --- a/src/posit/connect/permissions.py +++ b/src/posit/connect/permissions.py @@ -1,11 +1,12 @@ +from __future__ import annotations + from typing import List, overload from requests.sessions import Session as Session -from posit.connect.config import Config - from . import urls +from .config import Config from .resources import Resource, Resources diff --git a/src/posit/connect/users.py b/src/posit/connect/users.py index f5598c1d..3bc52474 100644 --- a/src/posit/connect/users.py +++ b/src/posit/connect/users.py @@ -160,10 +160,12 @@ def __init__(self, config: Config, session: requests.Session) -> None: @overload def find( self, prefix: str = ..., user_role: str = ..., account_status: str = ... - ) -> List[User]: ... + ) -> List[User]: + ... @overload - def find(self, *args, **kwargs) -> List[User]: ... + def find(self, *args, **kwargs) -> List[User]: + ... def find(self, *args, **kwargs): params = dict(*args, **kwargs) @@ -181,10 +183,12 @@ def find(self, *args, **kwargs): @overload def find_one( self, prefix: str = ..., user_role: str = ..., account_status: str = ... - ) -> User | None: ... + ) -> User | None: + ... @overload - def find_one(self, *args, **kwargs) -> User | None: ... + def find_one(self, *args, **kwargs) -> User | None: + ... def find_one(self, *args, **kwargs) -> User | None: params = dict(*args, **kwargs) From f48d7d329a0f3160f49cf84deeb21a534b92303a Mon Sep 17 00:00:00 2001 From: tdstein Date: Tue, 2 Apr 2024 11:47:29 -0400 Subject: [PATCH 3/4] fix: replaces erroneous permission guid ref with id --- src/posit/connect/permissions.py | 18 +++----------- tests/posit/connect/test_permissions.py | 33 +++++++------------------ 2 files changed, 12 insertions(+), 39 deletions(-) diff --git a/src/posit/connect/permissions.py b/src/posit/connect/permissions.py index d472d284..ea370217 100644 --- a/src/posit/connect/permissions.py +++ b/src/posit/connect/permissions.py @@ -12,20 +12,8 @@ class Permission(Resource): @property - def guid(self) -> str: - """The globally unique identifier. - - An alias for the field 'id'. - - The conventional name for this field is 'guid'. As of v2024.03.0, the API uses the field name 'id'. - - Returns - ------- - str - """ - # Alias 'id' to 'guid'. - # The conventional name for this field across applications is 'guid'. - return self.get("id", self.get("guid")) # type: ignore + def id(self) -> str: + return self.get("id") # type: ignore @property def content_guid(self) -> str: @@ -62,7 +50,7 @@ def update(self, *args, **kwargs) -> None: def update(self, *args, **kwargs) -> None: """Update a permission.""" body = dict(*args, **kwargs) - path = f"v1/content/{self.content_guid}/permissions/{self.guid}" + path = f"v1/content/{self.content_guid}/permissions/{self.id}" url = urls.append_path(self.config.url, path) response = self.session.put( url, diff --git a/tests/posit/connect/test_permissions.py b/tests/posit/connect/test_permissions.py index eff051b7..c89e4693 100644 --- a/tests/posit/connect/test_permissions.py +++ b/tests/posit/connect/test_permissions.py @@ -1,3 +1,4 @@ +import random import uuid from unittest.mock import Mock @@ -11,27 +12,11 @@ from posit.connect.permissions import Permission, Permissions -class TestPermissionGuid: - def test_from_id(self): - config = Mock() - session = Mock() - guid = str(uuid.uuid4()) - permission = Permission(config, session, guid=guid) - assert permission.guid == guid - - def test_from_guid(self): - config = Mock() - session = Mock() - guid = str(uuid.uuid4()) - permission = Permission(config, session, id=guid) - assert permission.guid == guid - - class TestPermissionUpdate: @responses.activate def test_request_shape(self): # test data - guid = str(uuid.uuid4()) + id = random.randint(0, 100) content_guid = str(uuid.uuid4()) principal_guid = str(uuid.uuid4()) principal_type = "principal_type" @@ -40,7 +25,7 @@ def test_request_shape(self): # define api behavior responses.put( - f"https://connect.example/__api__/v1/content/{content_guid}/permissions/{guid}", + f"https://connect.example/__api__/v1/content/{content_guid}/permissions/{id}", json={ # doesn't matter for this test }, @@ -65,7 +50,7 @@ def test_request_shape(self): permission = Permission( config, session, - guid=guid, + id=id, content_guid=content_guid, principal_guid=principal_guid, principal_type=principal_type, @@ -83,10 +68,10 @@ def test_role_update(self): new_role = "new_role" # define api behavior - guid = str(uuid.uuid4()) + id = random.randint(0, 100) content_guid = str(uuid.uuid4()) responses.put( - f"https://connect.example/__api__/v1/content/{content_guid}/permissions/{guid}", + f"https://connect.example/__api__/v1/content/{content_guid}/permissions/{id}", json={"role": new_role}, match=[ matchers.json_params_matcher( @@ -103,7 +88,7 @@ def test_role_update(self): config = Config(api_key="12345", url="https://connect.example/") session = requests.Session() permission = Permission( - config, session, guid=guid, content_guid=content_guid, role=old_role + config, session, id=id, content_guid=content_guid, role=old_role ) # assert role change with respect to api response @@ -159,14 +144,14 @@ def test(self): content_guid = str(uuid.uuid4()) fake_permissions = [ { - "guid": str(uuid.uuid4()), + "id": random.randint(0, 100), "content_guid": content_guid, "principal_guid": str(uuid.uuid4()), "principal_type": "user", "role": "read", }, { - "guid": str(uuid.uuid4()), + "id": random.randint(0, 100), "content_guid": content_guid, "principal_guid": str(uuid.uuid4()), "principal_type": "group", From 5f5a5aa5023d0340e1db962eaa5e9d76201b0efc Mon Sep 17 00:00:00 2001 From: tdstein Date: Tue, 2 Apr 2024 11:59:09 -0400 Subject: [PATCH 4/4] chore: moves fake data to json files --- .../permissions.json | 16 ++++++ .../permissions/94.json | 7 +++ tests/posit/connect/test_permissions.py | 53 ++++++------------- 3 files changed, 39 insertions(+), 37 deletions(-) create mode 100644 tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions.json create mode 100644 tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions/94.json diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions.json new file mode 100644 index 00000000..9db6f6bf --- /dev/null +++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions.json @@ -0,0 +1,16 @@ +[ + { + "id": 94, + "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "principal_guid": "78974391-d89f-4f11-916a-ba50cfe993db", + "principal_type": "user", + "role": "owner" + }, + { + "id": 59, + "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "principal_guid": "75b95fc0-ae02-4d85-8732-79a845143eed", + "principal_type": "group", + "role": "viewer" + } +] diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions/94.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions/94.json new file mode 100644 index 00000000..491db40c --- /dev/null +++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions/94.json @@ -0,0 +1,7 @@ +{ + "id": 94, + "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "principal_guid": "78974391-d89f-4f11-916a-ba50cfe993db", + "principal_type": "user", + "role": "owner" +} diff --git a/tests/posit/connect/test_permissions.py b/tests/posit/connect/test_permissions.py index c89e4693..4136c422 100644 --- a/tests/posit/connect/test_permissions.py +++ b/tests/posit/connect/test_permissions.py @@ -11,6 +11,8 @@ from posit.connect.config import Config from posit.connect.permissions import Permission, Permissions +from .api import load_mock # type: ignore + class TestPermissionUpdate: @responses.activate @@ -64,15 +66,22 @@ def test_request_shape(self): @responses.activate def test_role_update(self): # test data - old_role = "old_role" - new_role = "new_role" + old_role = "owner" + new_role = "viewer" + + id = "94" + content_guid = "f2f37341-e21d-3d80-c698-a935ad614066" + fake_permission = { + **load_mock(f"v1/content/{content_guid}/permissions/{id}.json"), + "role": old_role, + } # define api behavior id = random.randint(0, 100) content_guid = str(uuid.uuid4()) responses.put( f"https://connect.example/__api__/v1/content/{content_guid}/permissions/{id}", - json={"role": new_role}, + json={**fake_permission, "role": new_role}, match=[ matchers.json_params_matcher( { @@ -101,23 +110,8 @@ class TestPermissionsFind: @responses.activate def test(self): # test data - content_guid = str(uuid.uuid4()) - fake_permissions = [ - { - "guid": str(uuid.uuid4()), - "content_guid": content_guid, - "principal_guid": str(uuid.uuid4()), - "principal_type": "user", - "role": "read", - }, - { - "guid": str(uuid.uuid4()), - "content_guid": content_guid, - "principal_guid": str(uuid.uuid4()), - "principal_type": "group", - "role": "write", - }, - ] + content_guid = "f2f37341-e21d-3d80-c698-a935ad614066" + fake_permissions = load_mock(f"v1/content/{content_guid}/permissions.json") # define api behavior responses.get( @@ -141,23 +135,8 @@ class TestPermissionsFindOne: @responses.activate def test(self): # test data - content_guid = str(uuid.uuid4()) - fake_permissions = [ - { - "id": random.randint(0, 100), - "content_guid": content_guid, - "principal_guid": str(uuid.uuid4()), - "principal_type": "user", - "role": "read", - }, - { - "id": random.randint(0, 100), - "content_guid": content_guid, - "principal_guid": str(uuid.uuid4()), - "principal_type": "group", - "role": "write", - }, - ] + content_guid = "f2f37341-e21d-3d80-c698-a935ad614066" + fake_permissions = load_mock(f"v1/content/{content_guid}/permissions.json") # define api behavior responses.get(