Repository: posit-dev/posit-sdk-py

Issue:
Add .update() method to Content
I think we need to do this as part of our first release because it will force us to address some basic issues, including:

* Can't inherit from TypedDict (#23)
* Passing around `session` everywhere. We don't currently pass it to `User()` or `ContentItem()`, perhaps we should just do that? Or should there be some global or other means of finding "the" session that corresponds to the URL of the resource you're holding?

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


> **updated for commit: `b9c6594` by [action](https://github.com/marketplace/actions/python-coverage)🐍**
tdstein: Converting back to draft until implementation and testing are complete.

Patch:
diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py
index e561074..7f6ad60 100644
--- a/src/posit/connect/client.py
+++ b/src/posit/connect/client.py
@@ -3,7 +3,7 @@ from __future__ import annotations
 from requests import Response, Session
 from typing import Optional
 
-from . import hooks, urls
+from . import hooks, me, urls
 
 from .auth import Auth
 from .config import Config
@@ -48,13 +48,7 @@ class Client:
 
     @property
     def me(self) -> User:
-        url = urls.append_path(self.config.url, "v1/user")
-        response = self.session.get(url)
-        body = response.json()
-        # Build the canonical URL for the User based on its GUID
-        guid = body["guid"]
-        url = urls.append_path(self.config.url, f"v1/users/{guid}")
-        return User(self.session, url, **body)
+        return me.get(self.config, self.session)
 
     @property
     def oauth(self) -> OAuthIntegration:
diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py
index 40e4a62..bc9de4a 100644
--- a/src/posit/connect/content.py
+++ b/src/posit/connect/content.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
-from typing import Callable, List, Optional
+from typing import Callable, List, Optional, overload
+
 
 from requests import Session
 
@@ -183,6 +184,88 @@ class ContentItem(Resource):
     def id(self) -> str:
         return self.get("id")  # type: ignore
 
+    @overload
+    def update(
+        self,
+        name: str = ...,
+        title: Optional[str] = ...,
+        description: str = ...,
+        access_type: str = ...,
+        owner_guid: Optional[str] = ...,
+        connection_timeout: Optional[int] = ...,
+        read_timeout: Optional[int] = ...,
+        init_timeout: Optional[int] = ...,
+        idle_timeout: Optional[int] = ...,
+        max_processes: Optional[int] = ...,
+        min_processes: Optional[int] = ...,
+        max_conns_per_process: Optional[int] = ...,
+        load_factor: Optional[float] = ...,
+        cpu_request: Optional[float] = ...,
+        cpu_limit: Optional[float] = ...,
+        memory_request: Optional[int] = ...,
+        memory_limit: Optional[int] = ...,
+        amd_gpu_limit: Optional[int] = ...,
+        nvidia_gpu_limit: Optional[int] = ...,
+        run_as: Optional[str] = ...,
+        run_as_current_user: Optional[bool] = ...,
+        default_image_name: Optional[str] = ...,
+        default_r_environment_management: Optional[bool] = ...,
+        default_py_environment_management: Optional[bool] = ...,
+        service_account_name: Optional[str] = ...,
+    ) -> None:
+        """
+        Update the content item.
+
+        Args:
+            name (str): The name of the content.
+            title (Optional[str]): The title of the content.
+            description (str): The description of the content.
+            access_type (str): The access type of the content.
+            owner_guid (Optional[str]): The owner GUID of the content.
+            connection_timeout (Optional[int]): The connection timeout in seconds.
+            read_timeout (Optional[int]): The read timeout in seconds.
+            init_timeout (Optional[int]): The initialization timeout in seconds.
+            idle_timeout (Optional[int]): The idle timeout in seconds.
+            max_processes (Optional[int]): The maximum number of processes.
+            min_processes (Optional[int]): The minimum number of processes.
+            max_conns_per_process (Optional[int]): The maximum number of connections per process.
+            load_factor (Optional[float]): The load factor.
+            cpu_request (Optional[float]): The CPU request.
+            cpu_limit (Optional[float]): The CPU limit.
+            memory_request (Optional[int]): The memory request in bytes.
+            memory_limit (Optional[int]): The memory limit in bytes.
+            amd_gpu_limit (Optional[int]): The AMD GPU limit.
+            nvidia_gpu_limit (Optional[int]): The NVIDIA GPU limit.
+            run_as (Optional[str]): The user to run as.
+            run_as_current_user (Optional[bool]): Whether to run as the current user.
+            default_image_name (Optional[str]): The default image name.
+            default_r_environment_management (Optional[bool]): Whether to use default R environment management.
+            default_py_environment_management (Optional[bool]): Whether to use default Python environment management.
+            service_account_name (Optional[str]): The service account name.
+
+        Returns:
+            None
+        """
+        ...
+
+    @overload
+    def update(self, *args, **kwargs) -> None: ...
+
+    def update(self, *args, **kwargs) -> None:
+        """
+        Update the content item.
+
+        Args:
+            *args
+            **kwargs
+
+        Returns:
+            None
+        """
+        body = dict(*args, **kwargs)
+        response = self.session.patch(self.url, json=body)
+        super().update(**response.json())
+
 
 class Content(Resources[ContentItem]):
     def __init__(self, config: Config, session: Session) -> None:
@@ -196,8 +279,8 @@ class Content(Resources[ContentItem]):
         results = self.session.get(self.url).json()
         items = (
             ContentItem(
+                config=self.config,
                 session=self.session,
-                url=urls.append_path(self.url, result["guid"]),
                 **result,
             )
             for result in results
@@ -210,8 +293,8 @@ class Content(Resources[ContentItem]):
         results = self.session.get(self.url).json()
         for result in results:
             item = ContentItem(
+                config=self.config,
                 session=self.session,
-                url=urls.append_path(self.url, result["guid"]),
                 **result,
             )
             if filter(item):
@@ -221,7 +304,7 @@ class Content(Resources[ContentItem]):
     def get(self, id: str) -> ContentItem:
         url = urls.append_path(self.url, id)
         response = self.session.get(url)
-        return ContentItem(self.session, url, **response.json())
+        return ContentItem(self.config, self.session, **response.json())
 
     def create(self) -> ContentItem:
         raise NotImplementedError()
diff --git a/src/posit/connect/me.py b/src/posit/connect/me.py
new file mode 100644
index 0000000..71ad7e5
--- /dev/null
+++ b/src/posit/connect/me.py
@@ -0,0 +1,22 @@
+import requests
+
+from . import urls
+
+from .config import Config
+from .users import User
+
+
+def get(config: Config, session: requests.Session) -> User:
+    """
+    Gets the current user.
+
+    Args:
+        config (Config): The configuration object containing the URL.
+        session (requests.Session): The session object used for making HTTP requests.
+
+    Returns:
+        User: The current user.
+    """
+    url = urls.append_path(config.url, "v1/user")
+    response = session.get(url)
+    return User(config, session, **response.json())
diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py
index 8a2986e..b5e77ea 100644
--- a/src/posit/connect/resources.py
+++ b/src/posit/connect/resources.py
@@ -5,17 +5,19 @@ from typing import Any, Generic, List, Optional, TypeVar
 
 import requests
 
+from .config import Config
+
 
 T = TypeVar("T")
 
 
 class Resource(ABC, dict):
-    def __init__(self, session: requests.Session, url: str, **kwargs):
+    def __init__(self, config: Config, session: requests.Session, **kwargs):
         super().__init__(**kwargs)
+        self.config: Config
+        super().__setattr__("config", config)
         self.session: requests.Session
         super().__setattr__("session", session)
-        self.url: str
-        super().__setattr__("url", url)
 
     def __getitem__(self, key):
         warnings.warn(
diff --git a/src/posit/connect/users.py b/src/posit/connect/users.py
index 41551b8..ba48be1 100644
--- a/src/posit/connect/users.py
+++ b/src/posit/connect/users.py
@@ -5,7 +5,7 @@ from typing import List, Optional
 import requests
 
 
-from . import urls
+from . import me, urls
 
 from .config import Config
 from .paginator import Paginator
@@ -57,10 +57,45 @@ class User(Resource):
     def locked(self) -> bool:
         return self.get("locked")  # type: ignore
 
+    def lock(self, *, force: bool = False):
+        """
+        Locks the user account.
+
+        .. warning:: You cannot unlock your own account. Once an account is locked, only an admin can unlock it.
+
+        Args:
+            force (bool, optional): If set to True, overrides lock protection allowing a user to lock their own account. Defaults to False.
+
+        Returns:
+            None
+        """
+        _me = me.get(self.config, self.session)
+        if _me.guid == self.guid and not force:
+            raise RuntimeError(
+                "You cannot lock your own account. Set force=True to override this behavior."
+            )
+        url = urls.append_path(self.config.url, f"v1/users/{self.guid}/lock")
+        body = {"locked": True}
+        self.session.post(url, json=body)
+        super().update(locked=True)
+
+    def unlock(self):
+        """
+        Unlocks the user account.
+
+        Returns:
+            None
+        """
+        url = urls.append_path(self.config.url, f"v1/users/{self.guid}/lock")
+        body = {"locked": False}
+        self.session.post(url, json=body)
+        super().update(locked=False)
+
     def _update(self, body):
         if len(body) == 0:
             return
-        response = self.session.put(self.url, json=body)
+        url = urls.append_path(self.config.url, f"v1/users/{self.guid}")
+        response = self.session.put(url, json=body)
         super().update(**response.json())
 
     def update(  # type: ignore
@@ -103,8 +138,8 @@ class Users(Resources[User]):
         results = paginator.fetch_results()
         return [
             User(
+                config=self.config,
                 session=self.session,
-                url=urls.append_path(self.url, user["guid"]),
                 **user,
             )
             for user in results
@@ -116,8 +151,8 @@ class Users(Resources[User]):
         results = (result for page in pages for result in page.results)
         users = (
             User(
+                config=self.config,
                 session=self.session,
-                url=urls.append_path(self.url, result["guid"]),
                 **result,
             )
             for result in results
@@ -125,13 +160,12 @@ class Users(Resources[User]):
         return next(users, None)
 
     def get(self, id: str) -> User:
-        url = urls.append_path(self.url, id)
+        url = urls.append_path(self.config.url, f"v1/users/{id}")
         response = self.session.get(url)
-        raw_user = response.json()
         return User(
+            config=self.config,
             session=self.session,
-            url=urls.append_path(self.url, raw_user["guid"]),
-            **raw_user,
+            **response.json(),
         )
 
     def create(self) -> User:


Test patch:
diff --git a/tests/posit/connect/__api__/v1/users/a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6.json b/tests/posit/connect/__api__/v1/users/a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6.json
new file mode 100644
index 0000000..d01b5f6
--- /dev/null
+++ b/tests/posit/connect/__api__/v1/users/a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6.json
@@ -0,0 +1,13 @@
+{
+    "email": "random_email@example.com",
+    "username": "random_username",
+    "first_name": "Random",
+    "last_name": "User",
+    "user_role": "admin",
+    "created_time": "2022-01-01T00:00:00Z",
+    "updated_time": "2022-03-15T12:34:56Z",
+    "active_time": "2022-02-28T18:30:00Z",
+    "confirmed": false,
+    "locked": true,
+    "guid": "a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6"
+}
diff --git a/tests/posit/connect/test_client.py b/tests/posit/connect/test_client.py
index 22b4ac8..fa51871 100644
--- a/tests/posit/connect/test_client.py
+++ b/tests/posit/connect/test_client.py
@@ -85,11 +85,6 @@ class TestClient:
 
         con = Client(api_key="12345", url="https://connect.example/")
         assert con.me.username == "carlos12"
-        # Check that the URL was constructed correctly
-        assert (
-            con.me.url
-            == "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4"
-        )
 
     def test_request(self, MockSession):
         api_key = "foobar"
diff --git a/tests/posit/connect/test_content.py b/tests/posit/connect/test_content.py
index 720644d..72030a9 100644
--- a/tests/posit/connect/test_content.py
+++ b/tests/posit/connect/test_content.py
@@ -6,6 +6,28 @@ from posit.connect.content import ContentItem
 from .api import load_mock  # type: ignore
 
 
+class TestContent:
+    @responses.activate
+    def test_update(self):
+        guid = "f2f37341-e21d-3d80-c698-a935ad614066"
+        responses.get(
+            f"https://connect.example/__api__/v1/content/{guid}",
+            json=load_mock(f"v1/content/{guid}.json"),
+        )
+        con = Client("12345", "https://connect.example")
+        content = con.content.get(guid)
+        assert content.guid == guid
+
+        new_name = "New Name"
+        responses.patch(
+            f"https://connect.example/__api__/v1/content/{guid}",
+            json={**load_mock(f"v1/content/{guid}.json"), "name": new_name},
+        )
+
+        content.update(name=new_name)
+        assert content.name == new_name
+
+
 class TestContents:
     @responses.activate
     def test_get_all_content(self):
diff --git a/tests/posit/connect/test_resources.py b/tests/posit/connect/test_resources.py
index 4dee451..92cec23 100644
--- a/tests/posit/connect/test_resources.py
+++ b/tests/posit/connect/test_resources.py
@@ -8,8 +8,8 @@ from requests.sessions import Session as Session
 
 from posit.connect.resources import Resource, Resources
 
+config = Mock()
 session = Mock()
-url = Mock()
 
 
 class FakeResource(Resource):
@@ -23,16 +23,16 @@ class TestResource:
         k = "foo"
         v = "bar"
         d = dict({k: v})
-        r = FakeResource(session, url, **d)
+        r = FakeResource(config, session, **d)
         assert r.session == session
-        assert r.url == url
+        assert r.config == config
 
     def test__getitem__(self):
         warnings.filterwarnings("ignore", category=FutureWarning)
         k = "foo"
         v = "bar"
         d = dict({k: v})
-        r = FakeResource(session, url, **d)
+        r = FakeResource(config, session, **d)
         assert r.__getitem__(k) == d.__getitem__(k)
         assert r[k] == d[k]
 
@@ -42,7 +42,7 @@ class TestResource:
         v1 = "bar"
         v2 = "baz"
         d = dict({k: v1})
-        r = FakeResource(session, url, **d)
+        r = FakeResource(config, session, **d)
         assert r[k] == v1
         r[k] = v2
         assert r[k] == v2
@@ -52,7 +52,7 @@ class TestResource:
         k = "foo"
         v = "bar"
         d = dict({k: v})
-        r = FakeResource(session, url, **d)
+        r = FakeResource(config, session, **d)
         assert k in r
         assert r[k] == v
         del r[k]
@@ -62,7 +62,7 @@ class TestResource:
         k = "foo"
         v = "bar"
         d = dict({k: v})
-        r = FakeResource(session, url, **d)
+        r = FakeResource(config, session, **d)
         assert r.foo == v
 
 
diff --git a/tests/posit/connect/test_users.py b/tests/posit/connect/test_users.py
index 237c2d7..c61e8c3 100644
--- a/tests/posit/connect/test_users.py
+++ b/tests/posit/connect/test_users.py
@@ -92,6 +92,87 @@ class TestUser:
         user = User(session, url, locked=False)
         assert user.locked is False
 
+    @responses.activate
+    def test_lock(self):
+        responses.get(
+            "https://connect.example/__api__/v1/users/a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6",
+            json=load_mock("v1/users/a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6.json"),
+        )
+        c = Client(api_key="12345", url="https://connect.example/")
+        user = c.users.get("a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6")
+        assert user.guid == "a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6"
+
+        responses.get(
+            "https://connect.example/__api__/v1/user",
+            json=load_mock("v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json"),
+        )
+        responses.post(
+            "https://connect.example/__api__/v1/users/a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6/lock",
+            match=[responses.matchers.json_params_matcher({"locked": True})],
+        )
+        user.lock()
+        assert user.locked
+
+    @responses.activate
+    def test_lock_self_true(self):
+        responses.get(
+            "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4",
+            json=load_mock("v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json"),
+        )
+        c = Client(api_key="12345", url="https://connect.example/")
+        user = c.users.get("20a79ce3-6e87-4522-9faf-be24228800a4")
+        assert user.guid == "20a79ce3-6e87-4522-9faf-be24228800a4"
+
+        responses.get(
+            "https://connect.example/__api__/v1/user",
+            json=load_mock("v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json"),
+        )
+        responses.post(
+            "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4/lock",
+            match=[responses.matchers.json_params_matcher({"locked": True})],
+        )
+        user.lock(force=True)
+        assert user.locked
+
+    @responses.activate
+    def test_lock_self_false(self):
+        responses.get(
+            "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4",
+            json=load_mock("v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json"),
+        )
+        c = Client(api_key="12345", url="https://connect.example/")
+        user = c.users.get("20a79ce3-6e87-4522-9faf-be24228800a4")
+        assert user.guid == "20a79ce3-6e87-4522-9faf-be24228800a4"
+
+        responses.get(
+            "https://connect.example/__api__/v1/user",
+            json=load_mock("v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json"),
+        )
+        responses.post(
+            "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4/lock",
+            match=[responses.matchers.json_params_matcher({"locked": True})],
+        )
+        with pytest.raises(RuntimeError):
+            user.lock(force=False)
+        assert not user.locked
+
+    @responses.activate
+    def test_unlock(self):
+        responses.get(
+            "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4",
+            json=load_mock("v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json"),
+        )
+        c = Client(api_key="12345", url="https://connect.example/")
+        user = c.users.get("20a79ce3-6e87-4522-9faf-be24228800a4")
+        assert user.guid == "20a79ce3-6e87-4522-9faf-be24228800a4"
+
+        responses.post(
+            "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4/lock",
+            match=[responses.matchers.json_params_matcher({"locked": False})],
+        )
+        user.unlock()
+        assert not user.locked
+
 
 class TestUsers:
     @responses.activate