diff --git a/src/posit/connect/bundles.py b/src/posit/connect/bundles.py index 3ace8751..f1538736 100644 --- a/src/posit/connect/bundles.py +++ b/src/posit/connect/bundles.py @@ -99,10 +99,18 @@ def delete(self) -> None: def deploy(self) -> tasks.Task: """Deploy the bundle. + Spawns an asynchronous task, which activates the bundle. + Returns ------- tasks.Task The task for the deployment. + + Examples + -------- + >>> task = bundle.deploy() + >>> task.wait_for() + None """ path = f"v1/content/{self.content_guid}/deploy" url = urls.append(self.config.url, path) diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index fff8038b..1ee0a6c7 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -5,7 +5,7 @@ from requests import Response, Session from typing import Optional -from . import config, hooks, me, metrics, urls +from . import config, hooks, me, metrics, tasks, urls from .auth import Auth from .config import Config @@ -77,6 +77,16 @@ def oauth(self) -> OAuthIntegration: """ return OAuthIntegration(config=self.config, session=self.session) + @property + def tasks(self) -> tasks.Tasks: + """The tasks resource interface. + + Returns + ------- + tasks.Tasks + """ + return tasks.Tasks(self.config, self.session) + @property def users(self) -> Users: """The users resource interface. diff --git a/src/posit/connect/tasks.py b/src/posit/connect/tasks.py index bb8a5e62..24aff6f9 100644 --- a/src/posit/connect/tasks.py +++ b/src/posit/connect/tasks.py @@ -112,7 +112,7 @@ def wait_for(self, sleep: int = 1) -> None: Parameters ---------- sleep : int, optional - Maximum number of seconds to wait between task status checks. + Maximum number of seconds to wait between status checks. """ while not self.is_finished: self.update(wait=sleep) diff --git a/tests/posit/connect/__api__/v1/tasks/jXhOhdm5OOSkGhJw.json b/tests/posit/connect/__api__/v1/tasks/jXhOhdm5OOSkGhJw.json new file mode 100644 index 00000000..c1308f5b --- /dev/null +++ b/tests/posit/connect/__api__/v1/tasks/jXhOhdm5OOSkGhJw.json @@ -0,0 +1,12 @@ +{ + "id": "jXhOhdm5OOSkGhJw", + "output": [ + "Building static content...", + "Launching static content..." + ], + "finished": true, + "code": 1, + "error": "Unable to render: Rendering exited abnormally: exit status 1", + "last": 2, + "result": null + } diff --git a/tests/posit/connect/test_bundles.py b/tests/posit/connect/test_bundles.py index 32ba4066..6f5b6269 100644 --- a/tests/posit/connect/test_bundles.py +++ b/tests/posit/connect/test_bundles.py @@ -4,6 +4,7 @@ import requests import responses +from responses import matchers from unittest import mock from posit.connect import Client @@ -124,6 +125,52 @@ def test(self): assert mock_bundle_delete.call_count == 1 +class TestBundleDeploy: + @responses.activate + def test(self): + content_guid = "f2f37341-e21d-3d80-c698-a935ad614066" + bundle_id = "101" + task_id = "jXhOhdm5OOSkGhJw" + + # behavior + mock_content_get = responses.get( + f"https://connect.example/__api__/v1/content/{content_guid}", + json=load_mock(f"v1/content/{content_guid}.json"), + ) + + mock_bundle_get = responses.get( + f"https://connect.example/__api__/v1/content/{content_guid}/bundles/{bundle_id}", + json=load_mock( + f"v1/content/{content_guid}/bundles/{bundle_id}.json" + ), + ) + + mock_bundle_deploy = responses.post( + f"https://connect.example/__api__/v1/content/{content_guid}/deploy", + match=[matchers.json_params_matcher({"bundle_id": bundle_id})], + json={"task_id": task_id}, + ) + + mock_tasks_get = responses.get( + f"https://connect.example/__api__/v1/tasks/{task_id}", + json=load_mock(f"v1/tasks/{task_id}.json"), + ) + + # setup + c = Client("12345", "https://connect.example") + bundle = c.content.get(content_guid).bundles.get(bundle_id) + + # invoke + task = bundle.deploy() + + # assert + task.id == task_id + assert mock_content_get.call_count == 1 + assert mock_bundle_get.call_count == 1 + assert mock_bundle_deploy.call_count == 1 + assert mock_tasks_get.call_count == 1 + + class TestBundleDownload: @mock.patch("builtins.open", new_callable=mock.mock_open) @responses.activate diff --git a/tests/posit/connect/test_content.py b/tests/posit/connect/test_content.py index bfe15575..21c2a5b0 100644 --- a/tests/posit/connect/test_content.py +++ b/tests/posit/connect/test_content.py @@ -188,6 +188,44 @@ def test(self): assert mock_delete.call_count == 1 +class TestContentDeploy: + @responses.activate + def test(self): + content_guid = "f2f37341-e21d-3d80-c698-a935ad614066" + bundle_id = "101" + task_id = "jXhOhdm5OOSkGhJw" + + # behavior + mock_content_get = responses.get( + f"https://connect.example/__api__/v1/content/{content_guid}", + json=load_mock(f"v1/content/{content_guid}.json"), + ) + + mock_content_deploy = responses.post( + f"https://connect.example/__api__/v1/content/{content_guid}/deploy", + match=[matchers.json_params_matcher({"bundle_id": None})], + json={"task_id": task_id}, + ) + + mock_tasks_get = responses.get( + f"https://connect.example/__api__/v1/tasks/{task_id}", + json=load_mock(f"v1/tasks/{task_id}.json"), + ) + + # setup + c = Client("12345", "https://connect.example") + content = c.content.get(content_guid) + + # invoke + task = content.deploy() + + # assert + task.id == task_id + assert mock_content_get.call_count == 1 + assert mock_content_deploy.call_count == 1 + assert mock_tasks_get.call_count == 1 + + class TestContentUpdate: @responses.activate def test_update(self): diff --git a/tests/posit/connect/test_tasks.py b/tests/posit/connect/test_tasks.py new file mode 100644 index 00000000..a58eb85a --- /dev/null +++ b/tests/posit/connect/test_tasks.py @@ -0,0 +1,157 @@ +import requests +import responses + +from responses import matchers + +from posit import connect +from posit.connect import tasks + +from .api import load_mock # type: ignore + + +class TestTaskAttributes: + def setup_class(cls): + cls.task = tasks.Task( + None, + None, + **load_mock("v1/tasks/jXhOhdm5OOSkGhJw.json"), + ) + + def test_id(self): + assert self.task.id == "jXhOhdm5OOSkGhJw" + + def test_is_finished(self): + assert self.task.is_finished + + def test_output(self): + assert self.task.output == [ + "Building static content...", + "Launching static content...", + ] + + def test_error_code(self): + assert self.task.error_code == 1 + + def test_error_message(self): + assert ( + self.task.error_message + == "Unable to render: Rendering exited abnormally: exit status 1" + ) + + def test_result(self): + assert self.task.result is None + + +class TestTaskUpdate: + @responses.activate + def test(self): + id = "jXhOhdm5OOSkGhJw" + + # behavior + mock_tasks_get = [0] * 2 + mock_tasks_get[0] = responses.get( + f"https://connect.example/__api__/v1/tasks/{id}", + json={**load_mock(f"v1/tasks/{id}.json"), "finished": False}, + ) + + mock_tasks_get[1] = responses.get( + f"https://connect.example/__api__/v1/tasks/{id}", + json={**load_mock(f"v1/tasks/{id}.json"), "finished": True}, + ) + + # setup + c = connect.Client("12345", "https://connect.example") + task = c.tasks.get(id) + assert not task.is_finished + + # invoke + task.update() + + # assert + assert task.is_finished + assert mock_tasks_get[0].call_count == 1 + assert mock_tasks_get[1].call_count == 1 + + @responses.activate + def test_with_params(self): + id = "jXhOhdm5OOSkGhJw" + params = {"first": 10, "wait": 10} + + # behavior + mock_tasks_get = [0] * 2 + mock_tasks_get[0] = responses.get( + f"https://connect.example/__api__/v1/tasks/{id}", + json={**load_mock(f"v1/tasks/{id}.json"), "finished": False}, + ) + + mock_tasks_get[1] = responses.get( + f"https://connect.example/__api__/v1/tasks/{id}", + json={**load_mock(f"v1/tasks/{id}.json"), "finished": True}, + match=[matchers.query_param_matcher(params)], + ) + + # setup + c = connect.Client("12345", "https://connect.example") + task = c.tasks.get(id) + assert not task.is_finished + + # invoke + task.update(**params) + + # assert + assert task.is_finished + assert mock_tasks_get[0].call_count == 1 + assert mock_tasks_get[1].call_count == 1 + + +class TestTaskWaitFor: + @responses.activate + def test(self): + id = "jXhOhdm5OOSkGhJw" + + # behavior + mock_tasks_get = [0] * 2 + mock_tasks_get[0] = responses.get( + f"https://connect.example/__api__/v1/tasks/{id}", + json={**load_mock(f"v1/tasks/{id}.json"), "finished": False}, + ) + + mock_tasks_get[1] = responses.get( + f"https://connect.example/__api__/v1/tasks/{id}", + json={**load_mock(f"v1/tasks/{id}.json"), "finished": True}, + ) + + # setup + c = connect.Client("12345", "https://connect.example") + task = c.tasks.get(id) + assert not task.is_finished + + # invoke + task.wait_for() + + # assert + assert task.is_finished + assert mock_tasks_get[0].call_count == 1 + assert mock_tasks_get[1].call_count == 1 + + +class TestTasksGet: + @responses.activate + def test(self): + id = "jXhOhdm5OOSkGhJw" + + # behavior + mock_tasks_get = responses.get( + f"https://connect.example/__api__/v1/tasks/{id}", + json={**load_mock(f"v1/tasks/{id}.json"), "finished": False}, + ) + + # setup + c = connect.Client("12345", "https://connect.example") + + # invoke + task = c.tasks.get(id) + + # assert + assert task.id == id + assert mock_tasks_get.call_count == 1