Repository: posit-dev/posit-sdk-py

Issue:
ContentItem.owner
Similar to #164 but less complicated because `GET v1/content?include=owner` always includes the owner.

If `owner` is not in the item's data, the owner `@property` should `GET v1/users/{owner_guid}`, insert the response into the data, and return it.

Discussion:

Patch:
diff --git a/integration/Makefile b/integration/Makefile
index 9c0eaab..af636a2 100644
--- a/integration/Makefile
+++ b/integration/Makefile
@@ -138,4 +138,4 @@ help:
 # on local network.
 test:
 	mkdir -p logs
-	CONNECT_VERSION=${CONNECT_VERSION} CONNECT_API_KEY="$(shell rsconnect bootstrap -i -s http://connect:3939 --raw)" $(PYTHON) -m pytest --junit-xml=./reports/$(CONNECT_VERSION).xml > ./logs/$(CONNECT_VERSION).log
+	CONNECT_VERSION=${CONNECT_VERSION} CONNECT_API_KEY="$(shell rsconnect bootstrap -i -s http://connect:3939 --raw)" $(PYTHON) -m pytest -s --junit-xml=./reports/$(CONNECT_VERSION).xml | tee ./logs/$(CONNECT_VERSION).log
diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py
index 188c6a4..50c3963 100644
--- a/src/posit/connect/content.py
+++ b/src/posit/connect/content.py
@@ -13,6 +13,7 @@ from .config import Config
 from .bundles import Bundles
 from .permissions import Permissions
 from .resources import Resources, Resource
+from .users import Users
 
 
 class ContentItemOwner(Resource):
@@ -146,6 +147,17 @@ class ContentItem(Resource):
     def permissions(self) -> Permissions:
         return Permissions(self.config, self.session, self.guid)
 
+    @property
+    def owner(self) -> ContentItemOwner:
+        if "owner" not in self:
+            # It is possible to get a content item that does not contain owner.
+            # "owner" is an optional additional request param.
+            # If it's not included, we can retrieve the information by `owner_guid`
+            self["owner"] = Users(self.config, self.session).get(
+                self.owner_guid
+            )
+        return ContentItemOwner(self.config, self.session, **self["owner"])
+
     # Properties
 
     @property
@@ -308,10 +320,6 @@ class ContentItem(Resource):
     def owner_guid(self) -> str:
         return self.get("owner_guid")  # type: ignore
 
-    @property
-    def owner(self) -> ContentItemOwner:
-        return self.get("owner", {})  # type: ignore
-
     @property
     def content_url(self) -> str:
         return self.get("content_url")  # type: ignore


Test patch:
diff --git a/integration/tests/posit/connect/test_client.py b/integration/tests/posit/connect/test_client.py
index 1352007..e7ac364 100644
--- a/integration/tests/posit/connect/test_client.py
+++ b/integration/tests/posit/connect/test_client.py
@@ -1,6 +1,3 @@
-import os
-import pytest
-
 from posit import connect
 
 
diff --git a/integration/tests/posit/connect/test_content.py b/integration/tests/posit/connect/test_content.py
new file mode 100644
index 0000000..d7fa2fb
--- /dev/null
+++ b/integration/tests/posit/connect/test_content.py
@@ -0,0 +1,33 @@
+from posit import connect
+
+
+class TestContent:
+    def setup_class(cls):
+        cls.client = connect.Client()
+        cls.item = cls.client.content.create(
+            name="Sample",
+            description="Simple sample content for testing",
+            access_type="acl",
+        )
+
+    def test_count(self):
+        assert self.client.content.count() == 1
+
+    def test_get(self):
+        assert self.client.content.get(self.item.guid) == self.item
+
+    def test_find(self):
+        assert self.client.content.find()
+
+    def test_find_one(self):
+        assert self.client.content.find_one()
+
+    def test_content_item_owner(self):
+        item = self.client.content.find_one(include=None)
+        owner = item.owner
+        assert owner.guid == self.client.me.guid
+
+    def test_content_item_owner_from_include(self):
+        item = self.client.content.find_one(include="owner")
+        owner = item.owner
+        assert owner.guid == self.client.me.guid
diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json
index 0715893..48f4b8b 100644
--- a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json
+++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json
@@ -37,7 +37,7 @@
     "default_py_environment_management": null,
     "run_as": null,
     "run_as_current_user": false,
-    "owner_guid": "87c12c08-11cd-4de1-8da3-12a7579c4998",
+    "owner_guid": "20a79ce3-6e87-4522-9faf-be24228800a4",
     "content_url": "https://connect.example/content/f2f37341-e21d-3d80-c698-a935ad614066/",
     "dashboard_url": "https://connect.example/connect/#/apps/f2f37341-e21d-3d80-c698-a935ad614066",
     "app_role": "viewer",
diff --git a/tests/posit/connect/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json b/tests/posit/connect/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json
index 5561d5a..8a6f6db 100644
--- a/tests/posit/connect/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json
+++ b/tests/posit/connect/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json
@@ -10,4 +10,4 @@
     "confirmed": true,
     "locked": false,
     "guid": "20a79ce3-6e87-4522-9faf-be24228800a4"
-}
\ No newline at end of file
+}
diff --git a/tests/posit/connect/test_content.py b/tests/posit/connect/test_content.py
index 21c2a5b..4980029 100644
--- a/tests/posit/connect/test_content.py
+++ b/tests/posit/connect/test_content.py
@@ -5,12 +5,34 @@ from responses import matchers
 
 from posit.connect.client import Client
 from posit.connect.config import Config
-from posit.connect.content import ContentItem
+from posit.connect.content import ContentItem, ContentItemOwner
 from posit.connect.permissions import Permissions
 
 from .api import load_mock  # type: ignore
 
 
+class TestContentOwnerAttributes:
+    @classmethod
+    def setup_class(cls):
+        guid = "20a79ce3-6e87-4522-9faf-be24228800a4"
+        config = Config(api_key="12345", url="https://connect.example/")
+        session = requests.Session()
+        fake_item = load_mock(f"v1/users/{guid}.json")
+        cls.item = ContentItemOwner(config, session, **fake_item)
+
+    def test_guid(self):
+        assert self.item.guid == "20a79ce3-6e87-4522-9faf-be24228800a4"
+
+    def test_username(self):
+        assert self.item.username == "carlos12"
+
+    def test_first_name(self):
+        assert self.item.first_name == "Carlos"
+
+    def test_last_name(self):
+        assert self.item.last_name == "User"
+
+
 class TestContentItemAttributes:
     @classmethod
     def setup_class(cls):
@@ -138,7 +160,7 @@ class TestContentItemAttributes:
         assert self.item.run_as_current_user is False
 
     def test_owner_guid(self):
-        assert self.item.owner_guid == "87c12c08-11cd-4de1-8da3-12a7579c4998"
+        assert self.item.owner_guid == "20a79ce3-6e87-4522-9faf-be24228800a4"
 
     def test_content_url(self):
         assert (
@@ -156,7 +178,7 @@ class TestContentItemAttributes:
         assert self.item.app_role == "viewer"
 
     def test_owner(self):
-        assert self.item.owner == {}
+        assert "owner" not in self.item
 
     def test_permissions(self):
         assert isinstance(self.item.permissions, Permissions)
@@ -165,6 +187,35 @@ class TestContentItemAttributes:
         assert self.item.tags == []
 
 
+class TestContentItemGetContentOwner:
+    @responses.activate
+    def test_owner(self):
+        mock_content = load_mock(
+            "v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json"
+        )
+        responses.get(
+            "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066",
+            json=mock_content,
+        )
+
+        mock_user_get = responses.get(
+            f"https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4",
+            json=load_mock(
+                f"v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json"
+            ),
+        )
+
+        c = Client("12345", "https://connect.example")
+        item = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066")
+        owner = item.owner
+        assert owner.guid == "20a79ce3-6e87-4522-9faf-be24228800a4"
+
+        # load a second time, assert tha owner is loaded from cached result
+        owner = item.owner
+        assert owner.guid == "20a79ce3-6e87-4522-9faf-be24228800a4"
+        assert mock_user_get.call_count == 1
+
+
 class TestContentItemDelete:
     @responses.activate
     def test(self):