Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased

### Added
- `deploy html` and `deploy manifest` now support deployment to Posit Cloud.

### Changed
- Cloud deployments accept the content id instead of application id in the --app-id field.
- The `app_id` field in application store files also stores the content id instead of the application id.
- Application store files include a `version` field, set to 1 for this release.

## Unreleased

### Fixed
- getdefaultlocale deprecated

Expand Down
3 changes: 1 addition & 2 deletions rsconnect/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,7 @@ def test_server(connect_server):
def test_rstudio_server(server: api.PositServer):
with api.PositClient(server) as client:
try:
result = client.get_current_user()
server.handle_bad_response(result)
client.get_current_user()
except RSConnectException as exc:
raise RSConnectException("Failed to verify with {} ({}).".format(server.remote_name, exc))

Expand Down
227 changes: 137 additions & 90 deletions rsconnect/api.py

Large diffs are not rendered by default.

18 changes: 11 additions & 7 deletions rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,12 @@ def wrapper(*args, **kwargs):
return wrapper


def rstudio_args(func):
def cloud_shinyapps_args(func):
@click.option(
"--account",
"-A",
envvar=["SHINYAPPS_ACCOUNT"],
help="The shinyapps.io account name.",
help="The shinyapps.io/Posit Cloud account name.",
)
@click.option(
"--token",
Expand Down Expand Up @@ -412,7 +412,7 @@ def bootstrap(
help="The path to trusted TLS CA certificates.",
)
@click.option("--verbose", "-v", is_flag=True, help="Print detailed messages.")
@rstudio_args
@cloud_shinyapps_args
@click.pass_context
def add(ctx, name, server, api_key, insecure, cacert, account, token, secret, verbose):

Expand Down Expand Up @@ -961,7 +961,7 @@ def deploy_voila(
)
@server_args
@content_args
@rstudio_args
@cloud_shinyapps_args
@click.argument("file", type=click.Path(exists=True, dir_okay=True, file_okay=True))
@cli_exception_handler
def deploy_manifest(
Expand Down Expand Up @@ -1133,12 +1133,13 @@ def deploy_quarto(
# noinspection SpellCheckingInspection,DuplicatedCode
@deploy.command(
name="html",
short_help="Deploy html content to Posit Connect.",
help=("Deploy an html file, or directory of html files with entrypoint, to Posit Connect."),
short_help="Deploy html content to Posit Connect or Posit Cloud.",
help=("Deploy an html file, or directory of html files with entrypoint, to Posit Connect or Posit Cloud."),
no_args_is_help=True,
)
@server_args
@content_args
@cloud_shinyapps_args
@click.option(
"--entrypoint",
"-e",
Expand Down Expand Up @@ -1177,6 +1178,9 @@ def deploy_html(
api_key: str = None,
insecure: bool = False,
cacert: typing.IO = None,
account: str = None,
token: str = None,
secret: str = None,
):
kwargs = locals()
ce = None
Expand Down Expand Up @@ -1218,7 +1222,7 @@ def generate_deploy_python(app_mode, alias, min_version):
)
@server_args
@content_args
@rstudio_args
@cloud_shinyapps_args
@click.option(
"--entrypoint",
"-e",
Expand Down
15 changes: 11 additions & 4 deletions rsconnect/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,19 +396,23 @@ class AppStore(DataStore):
* App GUID
* Title
* App mode
* App store file version

The metadata file for an app is written in the same directory as the app's
entry point file, if that directory is writable. Otherwise, it is stored
in the user's config directory under `applications/{hash}.json` where the
hash is derived from the entry point file name.
hash is derived from the entry point file name. The file contains a version
field, which is incremented when backwards-incompatible file format changes
are made.
"""

def __init__(self, app_file):
def __init__(self, app_file, version=1):
base_name = str(basename(app_file).rsplit(".", 1)[0]) + ".json"
super(AppStore, self).__init__(
join(dirname(app_file), "rsconnect-python", base_name),
join(config_dirname(), "applications", sha1(abspath(app_file)) + ".json"),
)
self.version = version

def get(self, server_url):
"""
Expand Down Expand Up @@ -446,14 +450,15 @@ def set(self, server_url, filename, app_url, app_id, app_guid, title, app_mode):
app_guid=app_guid,
title=title,
app_mode=app_mode.name() if isinstance(app_mode, AppMode) else app_mode,
app_store_version=self.version,
),
)

def resolve(self, server, app_id, app_mode):
metadata = self.get(server)
if metadata is None:
logger.debug("No previous deployment to this server was found; this will be a new deployment.")
return app_id, app_mode
return app_id, app_mode, self.version

logger.debug("Found previous deployment data in %s" % self.get_path())

Expand All @@ -463,7 +468,9 @@ def resolve(self, server, app_id, app_mode):

# app mode cannot be changed on redeployment
app_mode = AppModes.get_by_name(metadata.get("app_mode"))
return app_id, app_mode

app_store_version = metadata.get("app_store_version")
return app_id, app_mode, app_store_version


DEFAULT_BUILD_DIR = join(os.getcwd(), "rsconnect-build")
Expand Down
193 changes: 192 additions & 1 deletion tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,20 @@
import sys
import io
from rsconnect.exception import RSConnectException
from rsconnect.models import AppModes
from .utils import (
require_api_key,
require_connect,
)
from rsconnect.api import RSConnectClient, RSConnectExecutor, RSConnectServer, _to_server_check_list
from rsconnect.api import (
RSConnectClient,
RSConnectExecutor,
RSConnectServer,
_to_server_check_list,
CloudService,
PositClient,
CloudServer,
)


class TestAPI(TestCase):
Expand Down Expand Up @@ -207,3 +216,185 @@ def test_deploy_existing_application_with_failure(self):
app_id = Mock()
with self.assertRaises(RSConnectException):
client.deploy(app_id, app_name=None, app_title=None, title_is_default=None, tarball=None)


class CloudServiceTestCase(TestCase):
def test_prepare_new_deploy(self):
cloud_client = Mock(spec=PositClient)
server = CloudServer("https://api.posit.cloud", "the_account", "the_token", "the_secret")
project_application_id = "20"
cloud_service = CloudService(
cloud_client=cloud_client, server=server, project_application_id=project_application_id
)

app_id = None
app_name = "my app"
bundle_size = 5000
bundle_hash = "the_hash"
app_mode = AppModes.PYTHON_SHINY

cloud_client.get_application.return_value = {
"content_id": 2,
}
cloud_client.get_content.return_value = {
"space_id": 1000,
}
cloud_client.create_output.return_value = {
"id": 1,
"source_id": 10,
"url": "https://posit.cloud/content/1",
}
cloud_client.create_bundle.return_value = {
"id": 100,
"presigned_url": "https://presigned.url",
"presigned_checksum": "the_checksum",
}

prepare_deploy_result = cloud_service.prepare_deploy(
app_id=app_id,
app_name=app_name,
bundle_size=bundle_size,
bundle_hash=bundle_hash,
app_mode=app_mode,
app_store_version=1,
)

cloud_client.get_application.assert_called_with(project_application_id)
cloud_client.get_content.assert_called_with(2)
cloud_client.create_output.assert_called_with(
name=app_name, application_type="connect", project_id=2, space_id=1000
)
cloud_client.create_bundle.assert_called_with(10, "application/x-tar", bundle_size, bundle_hash)

assert prepare_deploy_result.app_id == 1
assert prepare_deploy_result.application_id == 10
assert prepare_deploy_result.app_url == "https://posit.cloud/content/1"
assert prepare_deploy_result.bundle_id == 100
assert prepare_deploy_result.presigned_url == "https://presigned.url"
assert prepare_deploy_result.presigned_checksum == "the_checksum"

def test_prepare_redeploy(self):
cloud_client = Mock(spec=PositClient)
server = CloudServer("https://api.posit.cloud", "the_account", "the_token", "the_secret")
project_application_id = "20"
cloud_service = CloudService(
cloud_client=cloud_client, server=server, project_application_id=project_application_id
)

app_id = 1
app_name = "my app"
bundle_size = 5000
bundle_hash = "the_hash"
app_mode = AppModes.PYTHON_SHINY

cloud_client.get_content.return_value = {"id": 1, "source_id": 10, "url": "https://posit.cloud/content/1"}
cloud_client.create_bundle.return_value = {
"id": 100,
"presigned_url": "https://presigned.url",
"presigned_checksum": "the_checksum",
}

prepare_deploy_result = cloud_service.prepare_deploy(
app_id=app_id,
app_name=app_name,
bundle_size=bundle_size,
bundle_hash=bundle_hash,
app_mode=app_mode,
app_store_version=1,
)
cloud_client.get_content.assert_called_with(1)
cloud_client.create_bundle.assert_called_with(10, "application/x-tar", bundle_size, bundle_hash)

assert prepare_deploy_result.app_id == 1
assert prepare_deploy_result.application_id == 10
assert prepare_deploy_result.app_url == "https://posit.cloud/content/1"
assert prepare_deploy_result.bundle_id == 100
assert prepare_deploy_result.presigned_url == "https://presigned.url"
assert prepare_deploy_result.presigned_checksum == "the_checksum"

def test_prepare_redeploy_static(self):
cloud_client = Mock(spec=PositClient)
server = CloudServer("https://api.posit.cloud", "the_account", "the_token", "the_secret")
project_application_id = "20"
cloud_service = CloudService(
cloud_client=cloud_client, server=server, project_application_id=project_application_id
)

app_id = 1
app_name = "my app"
bundle_size = 5000
bundle_hash = "the_hash"
app_mode = AppModes.STATIC

cloud_client.get_content.return_value = {"id": 1, "source_id": 10, "url": "https://posit.cloud/content/1"}
cloud_client.create_revision.return_value = {
"application_id": 11,
}
cloud_client.create_bundle.return_value = {
"id": 100,
"presigned_url": "https://presigned.url",
"presigned_checksum": "the_checksum",
}

prepare_deploy_result = cloud_service.prepare_deploy(
app_id=app_id,
app_name=app_name,
bundle_size=bundle_size,
bundle_hash=bundle_hash,
app_mode=app_mode,
app_store_version=1,
)
cloud_client.get_content.assert_called_with(1)
cloud_client.create_revision.assert_called_with(1)
cloud_client.create_bundle.assert_called_with(11, "application/x-tar", bundle_size, bundle_hash)

assert prepare_deploy_result.app_id == 1
assert prepare_deploy_result.application_id == 11
assert prepare_deploy_result.app_url == "https://posit.cloud/content/1"
assert prepare_deploy_result.bundle_id == 100
assert prepare_deploy_result.presigned_url == "https://presigned.url"
assert prepare_deploy_result.presigned_checksum == "the_checksum"

def test_prepare_redeploy_preversioned_app_store(self):
cloud_client = Mock(spec=PositClient)
server = CloudServer("https://api.posit.cloud", "the_account", "the_token", "the_secret")
project_application_id = "20"
cloud_service = CloudService(
cloud_client=cloud_client, server=server, project_application_id=project_application_id
)

app_id = 10
app_name = "my app"
bundle_size = 5000
bundle_hash = "the_hash"
app_mode = AppModes.PYTHON_SHINY

cloud_client.get_application.return_value = {
"id": 10,
"content_id": 1,
}
cloud_client.get_content.return_value = {"id": 1, "source_id": 10, "url": "https://posit.cloud/content/1"}
cloud_client.create_bundle.return_value = {
"id": 100,
"presigned_url": "https://presigned.url",
"presigned_checksum": "the_checksum",
}

prepare_deploy_result = cloud_service.prepare_deploy(
app_id=app_id,
app_name=app_name,
bundle_size=bundle_size,
bundle_hash=bundle_hash,
app_mode=app_mode,
app_store_version=None,
)
cloud_client.get_application.assert_called_with(10)
cloud_client.get_content.assert_called_with(1)
cloud_client.create_bundle.assert_called_with(10, "application/x-tar", bundle_size, bundle_hash)

assert prepare_deploy_result.app_id == 1
assert prepare_deploy_result.application_id == 10
assert prepare_deploy_result.app_url == "https://posit.cloud/content/1"
assert prepare_deploy_result.bundle_id == 100
assert prepare_deploy_result.presigned_url == "https://presigned.url"
assert prepare_deploy_result.presigned_checksum == "the_checksum"
Loading