Repository: posit-dev/posit-sdk-py

Issue:
Add `owner` details to the output of `connect.content.find()`
for consistency with R `connectapi` package, the output of `connect.content.find()` should include an owner column with details of the content item owner(`owner$guid`, `owner$first_name`, `owner$last_name` and `owner$username`). 
This would enable any audit functionality we are building in the cookbooks as we would be able to get both content and owner details in one function(something which can already be done in R).

Discussion:
github-actions[bot]: # ☂️ Python Coverage
> current status: ✅
## Overall Coverage
| Lines | Covered | Coverage | Threshold | Status |
| :---: | :-----: | :------: | :-------: | :----: |
|  607  |   571   |    94%   |    80%    |   🟢   |
## New Files
No new covered files...
## Modified Files
| File                         | Coverage | Status |
| :--------------------------- | :------: | :----: |
| src/posit/connect/content.py |   100%   |   🟢   |
| **TOTAL**                    | **100%** |   🟢   |


> **updated for commit: `fc93a98` by [action](https://github.com/marketplace/actions/python-coverage)🐍**

Patch:
diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py
index da59f8e..435cc3c 100644
--- a/src/posit/connect/content.py
+++ b/src/posit/connect/content.py
@@ -190,6 +190,10 @@ class ContentItem(Resource):
     def owner_guid(self) -> str:
         return self.get("owner_guid")  # type: ignore
 
+    @property
+    def owner(self) -> str:
+        return self.get("owner", {})  # type: ignore
+
     @property
     def content_url(self) -> str:
         return self.get("content_url")  # type: ignore
@@ -202,6 +206,10 @@ class ContentItem(Resource):
     def app_role(self) -> str:
         return self.get("app_role")  # type: ignore
 
+    @property
+    def tags(self) -> List[dict]:
+        return self.get("tags", [])
+
     # CRUD Methods
 
     def delete(self) -> None:
@@ -386,14 +394,64 @@ class Content(Resources):
         response = self.session.post(url, json=body)
         return ContentItem(self.config, self.session, **response.json())
 
-    def find(self) -> List[ContentItem]:
+    @overload
+    def find(
+        self,
+        owner_guid: str = ...,
+        name: str = ...,
+        include: Optional[str] = "owner,tags",
+    ) -> List[ContentItem]:
         """Find content items.
 
+        Parameters
+        ----------
+        owner_guid : str, optional
+            The owner's unique identifier, by default ...
+        name : str, optional
+            The simple URL friendly name, by default ...
+        include : Optional[str], optional
+            Comma separated list of details to include in the response, allows 'owner' and 'tags', by default 'owner,tags'
+
         Returns
         -------
         List[ContentItem]
         """
-        results = self.session.get(self.url).json()
+        ...
+
+    @overload
+    def find(
+        self, *args, include: Optional[str] = "owner,tags", **kwargs
+    ) -> List[ContentItem]:
+        """Find content items.
+
+        Parameters
+        ----------
+        include : Optional[str], optional
+            Comma separated list of details to include in the response, allows 'owner' and 'tags', by default 'owner,tags'
+
+        Returns
+        -------
+        List[ContentItem]
+        """
+        ...
+
+    def find(
+        self, *args, include: Optional[str] = "owner,tags", **kwargs
+    ) -> List[ContentItem]:
+        """Find content items.
+
+        Parameters
+        ----------
+        include : Optional[str], optional
+            Comma separated list of details to include in the response, allows 'owner' and 'tags', by default 'owner,tags'
+
+        Returns
+        -------
+        List[ContentItem]
+        """
+        params = dict(*args, include=include, **kwargs)
+        response = self.session.get(self.url, params=params)
+        results = response.json()
         items = (
             ContentItem(
                 config=self.config,
@@ -404,23 +462,63 @@ class Content(Resources):
         )
         return [item for item in items]
 
-    def find_one(self) -> ContentItem | None:
-        """Find a content item.
+    @overload
+    def find_one(
+        self,
+        owner_guid: str = ...,
+        name: str = ...,
+        include: Optional[str] = "owner,tags",
+    ) -> ContentItem | None:
+        """Find content items.
+
+        Parameters
+        ----------
+        owner_guid : str, optional
+            The owner's unique identifier, by default ...
+        name : str, optional
+            The simple URL friendly name, by default ...
+        include : Optional[str], optional
+            Comma separated list of details to include in the response, allows 'owner' and 'tags', by default 'owner,tags'
 
         Returns
         -------
         ContentItem | None
         """
-        results = self.session.get(self.url).json()
-        items = (
-            ContentItem(
-                config=self.config,
-                session=self.session,
-                **result,
-            )
-            for result in results
-        )
-        return next(items, None)
+        ...
+
+    @overload
+    def find_one(
+        self, *args, include: Optional[str] = "owner,tags", **kwargs
+    ) -> ContentItem | None:
+        """Find content items.
+
+        Parameters
+        ----------
+        include : Optional[str], optional
+            Comma separated list of details to include in the response, allows 'owner' and 'tags', by default 'owner,tags'
+
+        Returns
+        -------
+        ContentItem | None
+        """
+        ...
+
+    def find_one(
+        self, *args, include: Optional[str] = "owner,tags", **kwargs
+    ) -> ContentItem | None:
+        """Find content items.
+
+        Parameters
+        ----------
+        include : Optional[str], optional
+            Comma separated list of details to include in the response, allows 'owner' and 'tags', by default 'owner,tags'
+
+        Returns
+        -------
+        ContentItem | None
+        """
+        items = self.find(*args, include=include, **kwargs)
+        return next(iter(items), None)
 
     def get(self, guid: str) -> ContentItem:
         """Get a content item.


Test patch:
diff --git a/tests/posit/connect/__api__/v1/content.json b/tests/posit/connect/__api__/v1/content.json
index 8190c9a..529f898 100644
--- a/tests/posit/connect/__api__/v1/content.json
+++ b/tests/posit/connect/__api__/v1/content.json
@@ -42,7 +42,13 @@
         "content_url": "https://connect.example/content/39c8d85a-37ae-4b8b-8655-30a06adff2f1/",
         "dashboard_url": "https://connect.example/connect/#/apps/39c8d85a-37ae-4b8b-8655-30a06adff2f1",
         "app_role": "viewer",
-        "id": "583"
+        "id": "583",
+        "owner": {
+            "guid": "a01792e3-2e67-402e-99af-be04a48da074",
+            "username": "al",
+            "first_name": "Alice",
+            "last_name": "User"
+        }
     },
     {
         "guid": "f2f37341-e21d-3d80-c698-a935ad614066",
@@ -87,7 +93,29 @@
         "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",
-        "id": "8274"
+        "id": "8274",
+        "tags": [
+            {
+                "id": "7",
+                "name": "Performance",
+                "parent_id": "5",
+                "created_time": "2019-10-08T19:45:23Z",
+                "updated_time": "2019-10-08T19:45:23Z"
+            },
+            {
+                "id": "34",
+                "name": "Data",
+                "parent_id": "3",
+                "created_time": "2023-05-18T16:41:59Z",
+                "updated_time": "2023-05-18T16:41:59Z"
+            }
+        ],
+        "owner": {
+            "guid": "87c12c08-11cd-4de1-8da3-12a7579c4998",
+            "username": "robert",
+            "first_name": "Bob",
+            "last_name": "Loblaw"
+        }
     },
     {
         "guid": "93a3cd6d-5a1b-236c-9808-6045f2a73fb5",
@@ -132,6 +160,12 @@
         "content_url": "https://connect.example/content/93a3cd6d-5a1b-236c-9808-6045f2a73fb5/",
         "dashboard_url": "https://connect.example/connect/#/apps/93a3cd6d-5a1b-236c-9808-6045f2a73fb5",
         "app_role": "viewer",
-        "id": "8462"
+        "id": "8462",
+        "owner": {
+            "guid": "20a79ce3-6e87-4522-9faf-be24228800a4",
+            "username": "carlos12",
+            "first_name": "Carlos",
+            "last_name": "User"
+        }
     }
-]
\ No newline at end of file
+]
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 bbf76fb..0715893 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
@@ -42,4 +42,4 @@
     "dashboard_url": "https://connect.example/connect/#/apps/f2f37341-e21d-3d80-c698-a935ad614066",
     "app_role": "viewer",
     "id": "8274"
-}
\ No newline at end of file
+}
diff --git a/tests/posit/connect/test_content.py b/tests/posit/connect/test_content.py
index 494d1c6..0f6a5f8 100644
--- a/tests/posit/connect/test_content.py
+++ b/tests/posit/connect/test_content.py
@@ -155,9 +155,15 @@ class TestContentItemAttributes:
     def test_app_role(self):
         assert self.item.app_role == "viewer"
 
+    def test_owner(self):
+        assert self.item.owner == {}
+
     def test_permissions(self):
         assert isinstance(self.item.permissions, Permissions)
 
+    def test_tags(self):
+        assert self.item.tags == []
+
 
 class TestContentItemDelete:
     @responses.activate
@@ -182,7 +188,7 @@ class TestContentItemDelete:
         assert mock_delete.call_count == 1
 
 
-class TestContent:
+class TestContentUpdate:
     @responses.activate
     def test_update(self):
         guid = "f2f37341-e21d-3d80-c698-a935ad614066"
@@ -230,36 +236,178 @@ class TestContentCreate:
         assert content_item.name == fake_content_item["name"]
 
 
-class TestContents:
+class TestContentsFind:
     @responses.activate
-    def test_get_all_content(self):
-        responses.get(
+    def test(self):
+        # behavior
+        mock_get = responses.get(
             "https://connect.example/__api__/v1/content",
             json=load_mock("v1/content.json"),
+            match=[matchers.query_param_matcher({"include": "owner,tags"})],
         )
-        con = Client("12345", "https://connect.example")
-        all_content = con.content.find()
-        assert len(all_content) == 3
-        assert [c.name for c in all_content] == [
-            "team-admin-dashboard",
-            "Performance-Data-1671216053560",
-            "My-Streamlit-app",
-        ]
+
+        # setup
+        client = Client("12345", "https://connect.example")
+
+        # invoke
+        content = client.content.find()
+
+        #  assert
+        assert mock_get.call_count == 1
+        assert len(content) == 3
+        assert content[0].name == "team-admin-dashboard"
+        assert content[1].name == "Performance-Data-1671216053560"
+        assert content[2].name == "My-Streamlit-app"
 
     @responses.activate
-    def test_content_find_one(self):
-        responses.get(
+    def test_params_include(self):
+        # behavior
+        mock_get = responses.get(
             "https://connect.example/__api__/v1/content",
             json=load_mock("v1/content.json"),
+            match=[matchers.query_param_matcher({"include": "tags"})],
         )
-        con = Client("12345", "https://connect.example")
 
-        one = con.content.find_one()
-        assert isinstance(one, ContentItem)
-        assert one.name == "team-admin-dashboard"
+        # setup
+        client = Client("12345", "https://connect.example")
+
+        # invoke
+        client.content.find(include="tags")
+
+        #  assert
+        assert mock_get.call_count == 1
+
+    @responses.activate
+    def test_params_include_none(self):
+        # behavior
+        mock_get = responses.get(
+            "https://connect.example/__api__/v1/content",
+            json=load_mock("v1/content.json"),
+            match=[matchers.query_param_matcher({})],
+        )
+
+        # setup
+        client = Client("12345", "https://connect.example")
+
+        # invoke
+        client.content.find(include=None)
+
+        #  assert
+        assert mock_get.call_count == 1
+
+
+class TestContentsFindOne:
+    @responses.activate
+    def test(self):
+        # behavior
+        mock_get = responses.get(
+            "https://connect.example/__api__/v1/content",
+            json=load_mock("v1/content.json"),
+            match=[matchers.query_param_matcher({"include": "owner,tags"})],
+        )
+
+        # setup
+        client = Client("12345", "https://connect.example")
+
+        # invoke
+        content_item = client.content.find_one()
+
+        #  assert
+        assert mock_get.call_count == 1
+        assert content_item
+
+    @responses.activate
+    def test_owner_guid(self):
+        # data
+        owner_guid = "a01792e3-2e67-402e-99af-be04a48da074"
+
+        # behavior
+        mock_get = responses.get(
+            "https://connect.example/__api__/v1/content",
+            json=load_mock("v1/content.json"),
+            match=[
+                matchers.query_param_matcher(
+                    {"owner_guid": owner_guid, "include": "owner,tags"}
+                )
+            ],
+        )
+
+        # setup
+        client = Client("12345", "https://connect.example")
+
+        # invoke
+        content_item = client.content.find_one(owner_guid=owner_guid)
+
+        #  assert
+        assert mock_get.call_count == 1
+        assert content_item
+        assert content_item.owner_guid == owner_guid
+
+    @responses.activate
+    def test_name(self):
+        # data
+        name = "team-admin-dashboard"
+
+        # behavior
+        mock_get = responses.get(
+            "https://connect.example/__api__/v1/content",
+            json=load_mock("v1/content.json"),
+            match=[
+                matchers.query_param_matcher({"name": name, "include": "owner,tags"})
+            ],
+        )
+
+        # setup
+        client = Client("12345", "https://connect.example")
+
+        # invoke
+        content_item = client.content.find_one(name=name)
+
+        #  assert
+        assert mock_get.call_count == 1
+        assert content_item
+        assert content_item.name == name
+
+    @responses.activate
+    def test_params_include(self):
+        # behavior
+        mock_get = responses.get(
+            "https://connect.example/__api__/v1/content",
+            json=load_mock("v1/content.json"),
+            match=[matchers.query_param_matcher({"include": "tags"})],
+        )
+
+        # setup
+        client = Client("12345", "https://connect.example")
+
+        # invoke
+        client.content.find_one(include="tags")
+
+        #  assert
+        assert mock_get.call_count == 1
+
+    @responses.activate
+    def test_params_include_none(self):
+        # behavior
+        mock_get = responses.get(
+            "https://connect.example/__api__/v1/content",
+            json=load_mock("v1/content.json"),
+            match=[matchers.query_param_matcher({})],
+        )
+
+        # setup
+        client = Client("12345", "https://connect.example")
+
+        # invoke
+        client.content.find_one(include=None)
+
+        #  assert
+        assert mock_get.call_count == 1
+
 
+class TestContentsGet:
     @responses.activate
-    def test_content_get(self):
+    def test(self):
         responses.get(
             "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066",
             json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json"),
@@ -268,8 +416,10 @@ class TestContents:
         get_one = con.content.get("f2f37341-e21d-3d80-c698-a935ad614066")
         assert get_one.name == "Performance-Data-1671216053560"
 
+
+class TestContentsCount:
     @responses.activate
-    def test_count(self):
+    def test(self):
         responses.get(
             "https://connect.example/__api__/v1/content",
             json=load_mock("v1/content.json"),