Skip to content

Commit ce92b31

Browse files
authored
feat: adds bundle create and download (#179)
1 parent c0a9f8b commit ce92b31

File tree

4 files changed

+329
-12
lines changed

4 files changed

+329
-12
lines changed

src/posit/connect/bundles.py

Lines changed: 109 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
11
from __future__ import annotations
2+
import io
23

3-
from typing import List
4-
5-
from requests.sessions import Session as Session
4+
import requests
65

7-
from posit.connect.config import Config
8-
9-
from . import urls
6+
from typing import List
107

11-
from .resources import Resources, Resource
8+
from . import config, resources, urls
129

1310

14-
class BundleMetadata(Resource):
11+
class BundleMetadata(resources.Resource):
1512
@property
1613
def source(self) -> str | None:
1714
return self.get("source")
@@ -37,7 +34,7 @@ def archive_sha1(self) -> str | None:
3734
return self.get("archive_sha1")
3835

3936

40-
class Bundle(Resource):
37+
class Bundle(resources.Resource):
4138
@property
4239
def id(self) -> str:
4340
return self["id"]
@@ -99,14 +96,115 @@ def delete(self) -> None:
9996
url = urls.append(self.config.url, path)
10097
self.session.delete(url)
10198

99+
def download(self, output: io.BufferedWriter | str):
100+
"""Download a bundle.
101+
102+
Download a bundle to a file or memory.
103+
104+
Parameters
105+
----------
106+
output: io.BufferedWriter | str
107+
An io.BufferedWriter instance or a str representing a relative or absolute path.
108+
109+
Raises
110+
------
111+
TypeError
112+
If the output is not of type `io.BufferedWriter` or `str`.
113+
114+
Examples
115+
--------
116+
Write to a file.
117+
>>> bundle.download("bundle.tar.gz")
118+
None
119+
120+
Write to an io.BufferedWriter.
121+
>>> with open('bundle.tar.gz', 'wb') as file:
122+
>>> bundle.download(file)
123+
None
124+
"""
125+
if not isinstance(output, (io.BufferedWriter, str)):
126+
raise TypeError(
127+
f"download() expected argument type 'io.BufferedWriter` or 'str', but got '{type(input).__name__}'"
128+
)
129+
130+
path = f"v1/content/{self.content_guid}/bundles/{self.id}/download"
131+
url = urls.append(self.config.url, path)
132+
response = self.session.get(url, stream=True)
133+
if isinstance(output, io.BufferedWriter):
134+
for chunk in response.iter_content():
135+
output.write(chunk)
136+
return
137+
138+
if isinstance(output, str):
139+
with open(output, "wb") as file:
140+
for chunk in response.iter_content():
141+
file.write(chunk)
142+
return
102143

103-
class Bundles(Resources):
144+
145+
class Bundles(resources.Resources):
104146
def __init__(
105-
self, config: Config, session: Session, content_guid: str
147+
self,
148+
config: config.Config,
149+
session: requests.Session,
150+
content_guid: str,
106151
) -> None:
107152
super().__init__(config, session)
108153
self.content_guid = content_guid
109154

155+
def create(self, input: io.BufferedReader | bytes | str) -> Bundle:
156+
"""Create a bundle.
157+
158+
Create a bundle from a file or memory.
159+
160+
Parameters
161+
----------
162+
input : io.BufferedReader | bytes | str
163+
Input archive for bundle creation. A 'str' type assumes a relative or absolute filepath.
164+
165+
Returns
166+
-------
167+
Bundle
168+
The created bundle.
169+
170+
Raises
171+
------
172+
TypeError
173+
If the input is not of type `io.BufferedReader`, `bytes`, or `str`.
174+
175+
Examples
176+
--------
177+
Create a bundle from io.BufferedReader
178+
>>> with open('bundle.tar.gz', 'rb') as file:
179+
>>> bundle.create(file)
180+
None
181+
182+
Create a bundle from bytes.
183+
>>> with open('bundle.tar.gz', 'rb') as file:
184+
>>> data: bytes = file.read()
185+
>>> bundle.create(data)
186+
None
187+
188+
Create a bundle from pathname.
189+
>>> bundle.create("bundle.tar.gz")
190+
None
191+
"""
192+
if isinstance(input, (io.BufferedReader, bytes)):
193+
data = input
194+
elif isinstance(input, str):
195+
with open(input, "rb") as file:
196+
data = file.read()
197+
else:
198+
raise TypeError(
199+
f"create() expected argument type 'io.BufferedReader', 'bytes', or 'str', but got '{type(input).__name__}'"
200+
)
201+
202+
path = f"v1/content/{self.content_guid}/bundles"
203+
url = urls.append(self.config.url, path)
204+
response = self.session.post(url, data=data)
205+
result = response.json()
206+
return Bundle(self.config, self.session, **result)
207+
110208
def find(self) -> List[Bundle]:
111209
path = f"v1/content/{self.content_guid}/bundles"
112210
url = urls.append(self.config.url, path)

tests/posit/connect/api.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,7 @@ def load_mock(path: str) -> dict:
2929
>>> data = load_mock("v1/example.jsonc")
3030
"""
3131
return json.loads((Path(__file__).parent / "__api__" / path).read_text())
32+
33+
34+
def get_path(path: str) -> Path:
35+
return Path(__file__).parent / "__api__" / path

tests/posit/connect/test_bundles.py

Lines changed: 216 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
import io
2+
3+
import pytest
14
import requests
25
import responses
36

7+
from unittest import mock
8+
49
from posit.connect import Client
510
from posit.connect.config import Config
611
from posit.connect.bundles import Bundle
712

8-
from .api import load_mock # type: ignore
13+
from .api import load_mock, get_path # type: ignore
914

1015

1116
class TestBundleProperties:
@@ -119,6 +124,216 @@ def test(self):
119124
assert mock_bundle_delete.call_count == 1
120125

121126

127+
class TestBundleDownload:
128+
@mock.patch("builtins.open", new_callable=mock.mock_open)
129+
@responses.activate
130+
def test_output_as_str(self, mock_file: mock.MagicMock):
131+
content_guid = "f2f37341-e21d-3d80-c698-a935ad614066"
132+
bundle_id = "101"
133+
path = get_path(
134+
f"v1/content/{content_guid}/bundles/{bundle_id}/download/bundle.tar.gz"
135+
)
136+
137+
# behavior
138+
mock_content_get = responses.get(
139+
f"https://connect.example/__api__/v1/content/{content_guid}",
140+
json=load_mock(f"v1/content/{content_guid}.json"),
141+
)
142+
143+
mock_bundle_get = responses.get(
144+
f"https://connect.example/__api__/v1/content/{content_guid}/bundles/{bundle_id}",
145+
json=load_mock(
146+
f"v1/content/{content_guid}/bundles/{bundle_id}.json"
147+
),
148+
)
149+
150+
mock_bundle_download = responses.get(
151+
f"https://connect.example/__api__/v1/content/{content_guid}/bundles/{bundle_id}/download",
152+
body=path.read_bytes(),
153+
)
154+
155+
# setup
156+
c = Client("12345", "https://connect.example")
157+
bundle = c.content.get(content_guid).bundles.get(bundle_id)
158+
159+
# invoke
160+
bundle.download("pathname")
161+
162+
# assert
163+
assert mock_content_get.call_count == 1
164+
assert mock_bundle_get.call_count == 1
165+
assert mock_bundle_download.call_count == 1
166+
mock_file.assert_called_once_with("pathname", "wb")
167+
168+
@responses.activate
169+
def test_output_as_io(self):
170+
content_guid = "f2f37341-e21d-3d80-c698-a935ad614066"
171+
bundle_id = "101"
172+
path = get_path(
173+
f"v1/content/{content_guid}/bundles/{bundle_id}/download/bundle.tar.gz"
174+
)
175+
176+
# behavior
177+
mock_content_get = responses.get(
178+
f"https://connect.example/__api__/v1/content/{content_guid}",
179+
json=load_mock(f"v1/content/{content_guid}.json"),
180+
)
181+
182+
mock_bundle_get = responses.get(
183+
f"https://connect.example/__api__/v1/content/{content_guid}/bundles/{bundle_id}",
184+
json=load_mock(
185+
f"v1/content/{content_guid}/bundles/{bundle_id}.json"
186+
),
187+
)
188+
189+
mock_bundle_download = responses.get(
190+
f"https://connect.example/__api__/v1/content/{content_guid}/bundles/{bundle_id}/download",
191+
body=path.read_bytes(),
192+
)
193+
194+
# setup
195+
c = Client("12345", "https://connect.example")
196+
bundle = c.content.get(content_guid).bundles.get(bundle_id)
197+
198+
# invoke
199+
file = io.BytesIO()
200+
buffer = io.BufferedWriter(file)
201+
bundle.download(buffer)
202+
buffer.seek(0)
203+
204+
# assert
205+
assert mock_content_get.call_count == 1
206+
assert mock_bundle_get.call_count == 1
207+
assert mock_bundle_download.call_count == 1
208+
assert file.read() == path.read_bytes()
209+
210+
@responses.activate
211+
def test_invalid_arguments(self):
212+
content_guid = "f2f37341-e21d-3d80-c698-a935ad614066"
213+
bundle_id = "101"
214+
path = get_path(
215+
f"v1/content/{content_guid}/bundles/{bundle_id}/download/bundle.tar.gz"
216+
)
217+
218+
# behavior
219+
mock_content_get = responses.get(
220+
f"https://connect.example/__api__/v1/content/{content_guid}",
221+
json=load_mock(f"v1/content/{content_guid}.json"),
222+
)
223+
224+
mock_bundle_get = responses.get(
225+
f"https://connect.example/__api__/v1/content/{content_guid}/bundles/{bundle_id}",
226+
json=load_mock(
227+
f"v1/content/{content_guid}/bundles/{bundle_id}.json"
228+
),
229+
)
230+
231+
mock_bundle_download = responses.get(
232+
f"https://connect.example/__api__/v1/content/{content_guid}/bundles/{bundle_id}/download",
233+
body=path.read_bytes(),
234+
)
235+
236+
# setup
237+
c = Client("12345", "https://connect.example")
238+
bundle = c.content.get(content_guid).bundles.get(bundle_id)
239+
240+
# invoke
241+
with pytest.raises(TypeError):
242+
bundle.download(None)
243+
244+
# assert
245+
assert mock_content_get.call_count == 1
246+
assert mock_bundle_get.call_count == 1
247+
248+
249+
class TestBundlesCreate:
250+
@responses.activate
251+
def test(self):
252+
content_guid = "f2f37341-e21d-3d80-c698-a935ad614066"
253+
bundle_id = "101"
254+
pathname = get_path(
255+
f"v1/content/{content_guid}/bundles/{bundle_id}/download/bundle.tar.gz"
256+
)
257+
258+
# behavior
259+
mock_content_get = responses.get(
260+
f"https://connect.example/__api__/v1/content/{content_guid}",
261+
json=load_mock(f"v1/content/{content_guid}.json"),
262+
)
263+
264+
mock_bundle_post = responses.post(
265+
f"https://connect.example/__api__/v1/content/{content_guid}/bundles",
266+
json=load_mock(
267+
f"v1/content/{content_guid}/bundles/{bundle_id}.json"
268+
),
269+
)
270+
271+
# setup
272+
c = Client("12345", "https://connect.example")
273+
content = c.content.get(content_guid)
274+
275+
# invoke
276+
data = pathname.read_bytes()
277+
bundle = content.bundles.create(data)
278+
279+
# # assert
280+
assert bundle.id == "101"
281+
assert mock_content_get.call_count == 1
282+
assert mock_bundle_post.call_count == 1
283+
284+
@responses.activate
285+
def test_kwargs_pathname(self):
286+
content_guid = "f2f37341-e21d-3d80-c698-a935ad614066"
287+
bundle_id = "101"
288+
pathname = get_path(
289+
f"v1/content/{content_guid}/bundles/{bundle_id}/download/bundle.tar.gz"
290+
)
291+
292+
# behavior
293+
mock_content_get = responses.get(
294+
f"https://connect.example/__api__/v1/content/{content_guid}",
295+
json=load_mock(f"v1/content/{content_guid}.json"),
296+
)
297+
298+
mock_bundle_post = responses.post(
299+
f"https://connect.example/__api__/v1/content/{content_guid}/bundles",
300+
json=load_mock(
301+
f"v1/content/{content_guid}/bundles/{bundle_id}.json"
302+
),
303+
)
304+
305+
# setup
306+
c = Client("12345", "https://connect.example")
307+
content = c.content.get(content_guid)
308+
309+
# invoke
310+
pathname = str(pathname.absolute())
311+
bundle = content.bundles.create(pathname)
312+
313+
# # assert
314+
assert bundle.id == "101"
315+
assert mock_content_get.call_count == 1
316+
assert mock_bundle_post.call_count == 1
317+
318+
@responses.activate
319+
def test_invalid_arguments(self):
320+
content_guid = "f2f37341-e21d-3d80-c698-a935ad614066"
321+
322+
# behavior
323+
responses.get(
324+
f"https://connect.example/__api__/v1/content/{content_guid}",
325+
json=load_mock(f"v1/content/{content_guid}.json"),
326+
)
327+
328+
# setup
329+
c = Client("12345", "https://connect.example")
330+
content = c.content.get(content_guid)
331+
332+
# invoke
333+
with pytest.raises(TypeError):
334+
content.bundles.create(None)
335+
336+
122337
class TestBundlesFind:
123338
@responses.activate
124339
def test(self):

0 commit comments

Comments
 (0)