Skip to content

Commit

Permalink
feat: adds bundle/content deploy and task management (#183)
Browse files Browse the repository at this point in the history
  • Loading branch information
tdstein authored Apr 29, 2024
1 parent ce92b31 commit 4fa2102
Show file tree
Hide file tree
Showing 8 changed files with 508 additions and 3 deletions.
25 changes: 24 additions & 1 deletion src/posit/connect/bundles.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from typing import List

from . import config, resources, urls
from . import config, resources, tasks, urls


class BundleMetadata(resources.Resource):
Expand Down Expand Up @@ -96,6 +96,29 @@ def delete(self) -> None:
url = urls.append(self.config.url, path)
self.session.delete(url)

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)
response = self.session.post(url, json={"bundle_id": self.id})
result = response.json()
ts = tasks.Tasks(self.config, self.session)
return ts.get(result["task_id"])

def download(self, output: io.BufferedWriter | str):
"""Download a bundle.
Expand Down
12 changes: 11 additions & 1 deletion src/posit/connect/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
25 changes: 24 additions & 1 deletion src/posit/connect/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from requests import Session

from . import urls
from . import tasks, urls

from .config import Config
from .bundles import Bundles
Expand Down Expand Up @@ -218,6 +218,29 @@ def delete(self) -> None:
url = urls.append(self.config.url, path)
self.session.delete(url)

def deploy(self) -> tasks.Task:
"""Deploy the content.
Spawns an asynchronous task, which activates the latest bundle.
Returns
-------
tasks.Task
The task for the deployment.
Examples
--------
>>> task = content.deploy()
>>> task.wait_for()
None
"""
path = f"v1/content/{self.guid}/deploy"
url = urls.append(self.config.url, path)
response = self.session.post(url, json={"bundle_id": None})
result = response.json()
ts = tasks.Tasks(self.config, self.session)
return ts.get(result["task_id"])

@overload
def update(
self,
Expand Down
195 changes: 195 additions & 0 deletions src/posit/connect/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
from __future__ import annotations

from typing import List, overload

from . import resources, urls


class Task(resources.Resource):
@property
def id(self) -> str:
"""The task identifier.
Returns
-------
str
"""
return self["id"]

@property
def is_finished(self) -> bool:
"""The task state.
If True, the task has completed. The task may have exited successfully
or have failed. Inspect the error_code to determine if the task finished
successfully or not.
Returns
-------
bool
"""
return self.get("finished", False)

@property
def output(self) -> List[str]:
"""Process output.
The process output produced by the task.
Returns
-------
List[str]
"""
return self["output"]

@property
def error_code(self) -> int | None:
"""The error code.
The error code produced by the task. A non-zero value represent an
error. A zero value represents no error.
Returns
-------
int | None
Non-zero value indicates an error.
"""
return self["code"] if self.is_finished else None

@property
def error_message(self) -> str | None:
"""The error message.
Returns
-------
str | None
Human readable error message, or None on success or not finished.
"""
return self.get("error") if self.is_finished else None

@property
def result(self) -> dict | None:
"""The task result.
Returns
-------
dict | None
"""
return self.get("result")

# CRUD Methods

@overload
def update(self, first: int, wait: int, **kwargs) -> None:
"""Update the task.
Parameters
----------
first : int, default 0
Line to start output on.
wait : int, default 0
Maximum number of seconds to wait for the task to complete.
"""
...

@overload
def update(self, *args, **kwargs) -> None:
"""Update the task."""
...

def update(self, *args, **kwargs) -> None:
"""Update the task.
See Also
--------
task.wait_for : Wait for the task to complete.
Notes
-----
When waiting for a task to complete, one should consider utilizing `task.wait_for`.
Examples
--------
>>> task.output
[
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
]
>>> task.update()
>>> task.output
[
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
"Pretium aenean pharetra magna ac placerat vestibulum lectus mauris."
]
"""
params = dict(*args, **kwargs)
path = f"v1/tasks/{self.id}"
url = urls.append(self.config.url, path)
response = self.session.get(url, params=params)
result = response.json()
super().update(**result)

def wait_for(self) -> None:
"""Wait for the task to finish.
Examples
--------
>>> task.wait_for()
None
"""
while not self.is_finished:
self.update()


class Tasks(resources.Resources):
@overload
def get(self, id: str, first: int, wait: int) -> Task:
"""Get a task.
Parameters
----------
id : str
Task identifier.
first : int, default 0
Line to start output on.
wait : int, default 0
Maximum number of seconds to wait for the task to complete.
Returns
-------
Task
"""
...

@overload
def get(self, id: str, *args, **kwargs) -> Task:
"""Get a task.
Parameters
----------
id : str
Task identifier.
Returns
-------
Task
"""
...

def get(self, id: str, *args, **kwargs) -> Task:
"""Get a task.
Parameters
----------
id : str
Task identifier.
Returns
-------
Task
"""
params = dict(*args, **kwargs)
path = f"v1/tasks/{id}"
url = urls.append(self.config.url, path)
response = self.session.get(url, params=params)
result = response.json()
return Task(self.config, self.session, **result)
12 changes: 12 additions & 0 deletions tests/posit/connect/__api__/v1/tasks/jXhOhdm5OOSkGhJw.json
Original file line number Diff line number Diff line change
@@ -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
}
47 changes: 47 additions & 0 deletions tests/posit/connect/test_bundles.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import requests
import responses

from responses import matchers
from unittest import mock

from posit.connect import Client
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 4fa2102

Please sign in to comment.