Skip to content

Commit 4fa2102

Browse files
authored
feat: adds bundle/content deploy and task management (#183)
1 parent ce92b31 commit 4fa2102

File tree

8 files changed

+508
-3
lines changed

8 files changed

+508
-3
lines changed

src/posit/connect/bundles.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from typing import List
77

8-
from . import config, resources, urls
8+
from . import config, resources, tasks, urls
99

1010

1111
class BundleMetadata(resources.Resource):
@@ -96,6 +96,29 @@ def delete(self) -> None:
9696
url = urls.append(self.config.url, path)
9797
self.session.delete(url)
9898

99+
def deploy(self) -> tasks.Task:
100+
"""Deploy the bundle.
101+
102+
Spawns an asynchronous task, which activates the bundle.
103+
104+
Returns
105+
-------
106+
tasks.Task
107+
The task for the deployment.
108+
109+
Examples
110+
--------
111+
>>> task = bundle.deploy()
112+
>>> task.wait_for()
113+
None
114+
"""
115+
path = f"v1/content/{self.content_guid}/deploy"
116+
url = urls.append(self.config.url, path)
117+
response = self.session.post(url, json={"bundle_id": self.id})
118+
result = response.json()
119+
ts = tasks.Tasks(self.config, self.session)
120+
return ts.get(result["task_id"])
121+
99122
def download(self, output: io.BufferedWriter | str):
100123
"""Download a bundle.
101124

src/posit/connect/client.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from requests import Response, Session
66
from typing import Optional
77

8-
from . import config, hooks, me, metrics, urls
8+
from . import config, hooks, me, metrics, tasks, urls
99

1010
from .auth import Auth
1111
from .config import Config
@@ -77,6 +77,16 @@ def oauth(self) -> OAuthIntegration:
7777
"""
7878
return OAuthIntegration(config=self.config, session=self.session)
7979

80+
@property
81+
def tasks(self) -> tasks.Tasks:
82+
"""The tasks resource interface.
83+
84+
Returns
85+
-------
86+
tasks.Tasks
87+
"""
88+
return tasks.Tasks(self.config, self.session)
89+
8090
@property
8191
def users(self) -> Users:
8292
"""The users resource interface.

src/posit/connect/content.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from requests import Session
99

10-
from . import urls
10+
from . import tasks, urls
1111

1212
from .config import Config
1313
from .bundles import Bundles
@@ -218,6 +218,29 @@ def delete(self) -> None:
218218
url = urls.append(self.config.url, path)
219219
self.session.delete(url)
220220

221+
def deploy(self) -> tasks.Task:
222+
"""Deploy the content.
223+
224+
Spawns an asynchronous task, which activates the latest bundle.
225+
226+
Returns
227+
-------
228+
tasks.Task
229+
The task for the deployment.
230+
231+
Examples
232+
--------
233+
>>> task = content.deploy()
234+
>>> task.wait_for()
235+
None
236+
"""
237+
path = f"v1/content/{self.guid}/deploy"
238+
url = urls.append(self.config.url, path)
239+
response = self.session.post(url, json={"bundle_id": None})
240+
result = response.json()
241+
ts = tasks.Tasks(self.config, self.session)
242+
return ts.get(result["task_id"])
243+
221244
@overload
222245
def update(
223246
self,

src/posit/connect/tasks.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
from __future__ import annotations
2+
3+
from typing import List, overload
4+
5+
from . import resources, urls
6+
7+
8+
class Task(resources.Resource):
9+
@property
10+
def id(self) -> str:
11+
"""The task identifier.
12+
13+
Returns
14+
-------
15+
str
16+
"""
17+
return self["id"]
18+
19+
@property
20+
def is_finished(self) -> bool:
21+
"""The task state.
22+
23+
If True, the task has completed. The task may have exited successfully
24+
or have failed. Inspect the error_code to determine if the task finished
25+
successfully or not.
26+
27+
Returns
28+
-------
29+
bool
30+
"""
31+
return self.get("finished", False)
32+
33+
@property
34+
def output(self) -> List[str]:
35+
"""Process output.
36+
37+
The process output produced by the task.
38+
39+
Returns
40+
-------
41+
List[str]
42+
"""
43+
return self["output"]
44+
45+
@property
46+
def error_code(self) -> int | None:
47+
"""The error code.
48+
49+
The error code produced by the task. A non-zero value represent an
50+
error. A zero value represents no error.
51+
52+
Returns
53+
-------
54+
int | None
55+
Non-zero value indicates an error.
56+
"""
57+
return self["code"] if self.is_finished else None
58+
59+
@property
60+
def error_message(self) -> str | None:
61+
"""The error message.
62+
63+
Returns
64+
-------
65+
str | None
66+
Human readable error message, or None on success or not finished.
67+
"""
68+
return self.get("error") if self.is_finished else None
69+
70+
@property
71+
def result(self) -> dict | None:
72+
"""The task result.
73+
74+
Returns
75+
-------
76+
dict | None
77+
"""
78+
return self.get("result")
79+
80+
# CRUD Methods
81+
82+
@overload
83+
def update(self, first: int, wait: int, **kwargs) -> None:
84+
"""Update the task.
85+
86+
Parameters
87+
----------
88+
first : int, default 0
89+
Line to start output on.
90+
wait : int, default 0
91+
Maximum number of seconds to wait for the task to complete.
92+
"""
93+
...
94+
95+
@overload
96+
def update(self, *args, **kwargs) -> None:
97+
"""Update the task."""
98+
...
99+
100+
def update(self, *args, **kwargs) -> None:
101+
"""Update the task.
102+
103+
See Also
104+
--------
105+
task.wait_for : Wait for the task to complete.
106+
107+
Notes
108+
-----
109+
When waiting for a task to complete, one should consider utilizing `task.wait_for`.
110+
111+
Examples
112+
--------
113+
>>> task.output
114+
[
115+
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
116+
]
117+
>>> task.update()
118+
>>> task.output
119+
[
120+
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
121+
"Pretium aenean pharetra magna ac placerat vestibulum lectus mauris."
122+
]
123+
"""
124+
params = dict(*args, **kwargs)
125+
path = f"v1/tasks/{self.id}"
126+
url = urls.append(self.config.url, path)
127+
response = self.session.get(url, params=params)
128+
result = response.json()
129+
super().update(**result)
130+
131+
def wait_for(self) -> None:
132+
"""Wait for the task to finish.
133+
134+
Examples
135+
--------
136+
>>> task.wait_for()
137+
None
138+
"""
139+
while not self.is_finished:
140+
self.update()
141+
142+
143+
class Tasks(resources.Resources):
144+
@overload
145+
def get(self, id: str, first: int, wait: int) -> Task:
146+
"""Get a task.
147+
148+
Parameters
149+
----------
150+
id : str
151+
Task identifier.
152+
first : int, default 0
153+
Line to start output on.
154+
wait : int, default 0
155+
Maximum number of seconds to wait for the task to complete.
156+
157+
Returns
158+
-------
159+
Task
160+
"""
161+
...
162+
163+
@overload
164+
def get(self, id: str, *args, **kwargs) -> Task:
165+
"""Get a task.
166+
167+
Parameters
168+
----------
169+
id : str
170+
Task identifier.
171+
172+
Returns
173+
-------
174+
Task
175+
"""
176+
...
177+
178+
def get(self, id: str, *args, **kwargs) -> Task:
179+
"""Get a task.
180+
181+
Parameters
182+
----------
183+
id : str
184+
Task identifier.
185+
186+
Returns
187+
-------
188+
Task
189+
"""
190+
params = dict(*args, **kwargs)
191+
path = f"v1/tasks/{id}"
192+
url = urls.append(self.config.url, path)
193+
response = self.session.get(url, params=params)
194+
result = response.json()
195+
return Task(self.config, self.session, **result)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"id": "jXhOhdm5OOSkGhJw",
3+
"output": [
4+
"Building static content...",
5+
"Launching static content..."
6+
],
7+
"finished": true,
8+
"code": 1,
9+
"error": "Unable to render: Rendering exited abnormally: exit status 1",
10+
"last": 2,
11+
"result": null
12+
}

tests/posit/connect/test_bundles.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import requests
55
import responses
66

7+
from responses import matchers
78
from unittest import mock
89

910
from posit.connect import Client
@@ -124,6 +125,52 @@ def test(self):
124125
assert mock_bundle_delete.call_count == 1
125126

126127

128+
class TestBundleDeploy:
129+
@responses.activate
130+
def test(self):
131+
content_guid = "f2f37341-e21d-3d80-c698-a935ad614066"
132+
bundle_id = "101"
133+
task_id = "jXhOhdm5OOSkGhJw"
134+
135+
# behavior
136+
mock_content_get = responses.get(
137+
f"https://connect.example/__api__/v1/content/{content_guid}",
138+
json=load_mock(f"v1/content/{content_guid}.json"),
139+
)
140+
141+
mock_bundle_get = responses.get(
142+
f"https://connect.example/__api__/v1/content/{content_guid}/bundles/{bundle_id}",
143+
json=load_mock(
144+
f"v1/content/{content_guid}/bundles/{bundle_id}.json"
145+
),
146+
)
147+
148+
mock_bundle_deploy = responses.post(
149+
f"https://connect.example/__api__/v1/content/{content_guid}/deploy",
150+
match=[matchers.json_params_matcher({"bundle_id": bundle_id})],
151+
json={"task_id": task_id},
152+
)
153+
154+
mock_tasks_get = responses.get(
155+
f"https://connect.example/__api__/v1/tasks/{task_id}",
156+
json=load_mock(f"v1/tasks/{task_id}.json"),
157+
)
158+
159+
# setup
160+
c = Client("12345", "https://connect.example")
161+
bundle = c.content.get(content_guid).bundles.get(bundle_id)
162+
163+
# invoke
164+
task = bundle.deploy()
165+
166+
# assert
167+
task.id == task_id
168+
assert mock_content_get.call_count == 1
169+
assert mock_bundle_get.call_count == 1
170+
assert mock_bundle_deploy.call_count == 1
171+
assert mock_tasks_get.call_count == 1
172+
173+
127174
class TestBundleDownload:
128175
@mock.patch("builtins.open", new_callable=mock.mock_open)
129176
@responses.activate

0 commit comments

Comments
 (0)