Skip to content

Commit

Permalink
feat: adds bundle create and download (#179)
Browse files Browse the repository at this point in the history
  • Loading branch information
tdstein authored Apr 25, 2024
1 parent c0a9f8b commit ce92b31
Show file tree
Hide file tree
Showing 4 changed files with 329 additions and 12 deletions.
120 changes: 109 additions & 11 deletions src/posit/connect/bundles.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
from __future__ import annotations
import io

from typing import List

from requests.sessions import Session as Session
import requests

from posit.connect.config import Config

from . import urls
from typing import List

from .resources import Resources, Resource
from . import config, resources, urls


class BundleMetadata(Resource):
class BundleMetadata(resources.Resource):
@property
def source(self) -> str | None:
return self.get("source")
Expand All @@ -37,7 +34,7 @@ def archive_sha1(self) -> str | None:
return self.get("archive_sha1")


class Bundle(Resource):
class Bundle(resources.Resource):
@property
def id(self) -> str:
return self["id"]
Expand Down Expand Up @@ -99,14 +96,115 @@ def delete(self) -> None:
url = urls.append(self.config.url, path)
self.session.delete(url)

def download(self, output: io.BufferedWriter | str):
"""Download a bundle.
Download a bundle to a file or memory.
Parameters
----------
output: io.BufferedWriter | str
An io.BufferedWriter instance or a str representing a relative or absolute path.
Raises
------
TypeError
If the output is not of type `io.BufferedWriter` or `str`.
Examples
--------
Write to a file.
>>> bundle.download("bundle.tar.gz")
None
Write to an io.BufferedWriter.
>>> with open('bundle.tar.gz', 'wb') as file:
>>> bundle.download(file)
None
"""
if not isinstance(output, (io.BufferedWriter, str)):
raise TypeError(
f"download() expected argument type 'io.BufferedWriter` or 'str', but got '{type(input).__name__}'"
)

path = f"v1/content/{self.content_guid}/bundles/{self.id}/download"
url = urls.append(self.config.url, path)
response = self.session.get(url, stream=True)
if isinstance(output, io.BufferedWriter):
for chunk in response.iter_content():
output.write(chunk)
return

if isinstance(output, str):
with open(output, "wb") as file:
for chunk in response.iter_content():
file.write(chunk)
return

class Bundles(Resources):

class Bundles(resources.Resources):
def __init__(
self, config: Config, session: Session, content_guid: str
self,
config: config.Config,
session: requests.Session,
content_guid: str,
) -> None:
super().__init__(config, session)
self.content_guid = content_guid

def create(self, input: io.BufferedReader | bytes | str) -> Bundle:
"""Create a bundle.
Create a bundle from a file or memory.
Parameters
----------
input : io.BufferedReader | bytes | str
Input archive for bundle creation. A 'str' type assumes a relative or absolute filepath.
Returns
-------
Bundle
The created bundle.
Raises
------
TypeError
If the input is not of type `io.BufferedReader`, `bytes`, or `str`.
Examples
--------
Create a bundle from io.BufferedReader
>>> with open('bundle.tar.gz', 'rb') as file:
>>> bundle.create(file)
None
Create a bundle from bytes.
>>> with open('bundle.tar.gz', 'rb') as file:
>>> data: bytes = file.read()
>>> bundle.create(data)
None
Create a bundle from pathname.
>>> bundle.create("bundle.tar.gz")
None
"""
if isinstance(input, (io.BufferedReader, bytes)):
data = input
elif isinstance(input, str):
with open(input, "rb") as file:
data = file.read()
else:
raise TypeError(
f"create() expected argument type 'io.BufferedReader', 'bytes', or 'str', but got '{type(input).__name__}'"
)

path = f"v1/content/{self.content_guid}/bundles"
url = urls.append(self.config.url, path)
response = self.session.post(url, data=data)
result = response.json()
return Bundle(self.config, self.session, **result)

def find(self) -> List[Bundle]:
path = f"v1/content/{self.content_guid}/bundles"
url = urls.append(self.config.url, path)
Expand Down
Binary file not shown.
4 changes: 4 additions & 0 deletions tests/posit/connect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,7 @@ def load_mock(path: str) -> dict:
>>> data = load_mock("v1/example.jsonc")
"""
return json.loads((Path(__file__).parent / "__api__" / path).read_text())


def get_path(path: str) -> Path:
return Path(__file__).parent / "__api__" / path
217 changes: 216 additions & 1 deletion tests/posit/connect/test_bundles.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import io

import pytest
import requests
import responses

from unittest import mock

from posit.connect import Client
from posit.connect.config import Config
from posit.connect.bundles import Bundle

from .api import load_mock # type: ignore
from .api import load_mock, get_path # type: ignore


class TestBundleProperties:
Expand Down Expand Up @@ -119,6 +124,216 @@ def test(self):
assert mock_bundle_delete.call_count == 1


class TestBundleDownload:
@mock.patch("builtins.open", new_callable=mock.mock_open)
@responses.activate
def test_output_as_str(self, mock_file: mock.MagicMock):
content_guid = "f2f37341-e21d-3d80-c698-a935ad614066"
bundle_id = "101"
path = get_path(
f"v1/content/{content_guid}/bundles/{bundle_id}/download/bundle.tar.gz"
)

# 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_download = responses.get(
f"https://connect.example/__api__/v1/content/{content_guid}/bundles/{bundle_id}/download",
body=path.read_bytes(),
)

# setup
c = Client("12345", "https://connect.example")
bundle = c.content.get(content_guid).bundles.get(bundle_id)

# invoke
bundle.download("pathname")

# assert
assert mock_content_get.call_count == 1
assert mock_bundle_get.call_count == 1
assert mock_bundle_download.call_count == 1
mock_file.assert_called_once_with("pathname", "wb")

@responses.activate
def test_output_as_io(self):
content_guid = "f2f37341-e21d-3d80-c698-a935ad614066"
bundle_id = "101"
path = get_path(
f"v1/content/{content_guid}/bundles/{bundle_id}/download/bundle.tar.gz"
)

# 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_download = responses.get(
f"https://connect.example/__api__/v1/content/{content_guid}/bundles/{bundle_id}/download",
body=path.read_bytes(),
)

# setup
c = Client("12345", "https://connect.example")
bundle = c.content.get(content_guid).bundles.get(bundle_id)

# invoke
file = io.BytesIO()
buffer = io.BufferedWriter(file)
bundle.download(buffer)
buffer.seek(0)

# assert
assert mock_content_get.call_count == 1
assert mock_bundle_get.call_count == 1
assert mock_bundle_download.call_count == 1
assert file.read() == path.read_bytes()

@responses.activate
def test_invalid_arguments(self):
content_guid = "f2f37341-e21d-3d80-c698-a935ad614066"
bundle_id = "101"
path = get_path(
f"v1/content/{content_guid}/bundles/{bundle_id}/download/bundle.tar.gz"
)

# 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_download = responses.get(
f"https://connect.example/__api__/v1/content/{content_guid}/bundles/{bundle_id}/download",
body=path.read_bytes(),
)

# setup
c = Client("12345", "https://connect.example")
bundle = c.content.get(content_guid).bundles.get(bundle_id)

# invoke
with pytest.raises(TypeError):
bundle.download(None)

# assert
assert mock_content_get.call_count == 1
assert mock_bundle_get.call_count == 1


class TestBundlesCreate:
@responses.activate
def test(self):
content_guid = "f2f37341-e21d-3d80-c698-a935ad614066"
bundle_id = "101"
pathname = get_path(
f"v1/content/{content_guid}/bundles/{bundle_id}/download/bundle.tar.gz"
)

# 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_post = responses.post(
f"https://connect.example/__api__/v1/content/{content_guid}/bundles",
json=load_mock(
f"v1/content/{content_guid}/bundles/{bundle_id}.json"
),
)

# setup
c = Client("12345", "https://connect.example")
content = c.content.get(content_guid)

# invoke
data = pathname.read_bytes()
bundle = content.bundles.create(data)

# # assert
assert bundle.id == "101"
assert mock_content_get.call_count == 1
assert mock_bundle_post.call_count == 1

@responses.activate
def test_kwargs_pathname(self):
content_guid = "f2f37341-e21d-3d80-c698-a935ad614066"
bundle_id = "101"
pathname = get_path(
f"v1/content/{content_guid}/bundles/{bundle_id}/download/bundle.tar.gz"
)

# 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_post = responses.post(
f"https://connect.example/__api__/v1/content/{content_guid}/bundles",
json=load_mock(
f"v1/content/{content_guid}/bundles/{bundle_id}.json"
),
)

# setup
c = Client("12345", "https://connect.example")
content = c.content.get(content_guid)

# invoke
pathname = str(pathname.absolute())
bundle = content.bundles.create(pathname)

# # assert
assert bundle.id == "101"
assert mock_content_get.call_count == 1
assert mock_bundle_post.call_count == 1

@responses.activate
def test_invalid_arguments(self):
content_guid = "f2f37341-e21d-3d80-c698-a935ad614066"

# behavior
responses.get(
f"https://connect.example/__api__/v1/content/{content_guid}",
json=load_mock(f"v1/content/{content_guid}.json"),
)

# setup
c = Client("12345", "https://connect.example")
content = c.content.get(content_guid)

# invoke
with pytest.raises(TypeError):
content.bundles.create(None)


class TestBundlesFind:
@responses.activate
def test(self):
Expand Down

0 comments on commit ce92b31

Please sign in to comment.