Skip to content

Commit 6edf6ec

Browse files
authored
Merge pull request #418 from rstudio/cloud-static-outputs
implement publishing static outputs for cloud
2 parents b510ee6 + 1d2f973 commit 6edf6ec

File tree

10 files changed

+564
-122
lines changed

10 files changed

+564
-122
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66
## Unreleased
77

8+
### Added
9+
- `deploy html` and `deploy manifest` now support deployment to Posit Cloud.
10+
11+
### Changed
12+
- Cloud deployments accept the content id instead of application id in the --app-id field.
13+
- The `app_id` field in application store files also stores the content id instead of the application id.
14+
- Application store files include a `version` field, set to 1 for this release.
15+
16+
## Unreleased
17+
818
### Fixed
919
- getdefaultlocale deprecated
1020

rsconnect/actions.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,7 @@ def test_server(connect_server):
224224
def test_rstudio_server(server: api.PositServer):
225225
with api.PositClient(server) as client:
226226
try:
227-
result = client.get_current_user()
228-
server.handle_bad_response(result)
227+
client.get_current_user()
229228
except RSConnectException as exc:
230229
raise RSConnectException("Failed to verify with {} ({}).".format(server.remote_name, exc))
231230

rsconnect/api.py

Lines changed: 137 additions & 90 deletions
Large diffs are not rendered by default.

rsconnect/main.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,12 @@ def wrapper(*args, **kwargs):
145145
return wrapper
146146

147147

148-
def rstudio_args(func):
148+
def cloud_shinyapps_args(func):
149149
@click.option(
150150
"--account",
151151
"-A",
152152
envvar=["SHINYAPPS_ACCOUNT"],
153-
help="The shinyapps.io account name.",
153+
help="The shinyapps.io/Posit Cloud account name.",
154154
)
155155
@click.option(
156156
"--token",
@@ -412,7 +412,7 @@ def bootstrap(
412412
help="The path to trusted TLS CA certificates.",
413413
)
414414
@click.option("--verbose", "-v", is_flag=True, help="Print detailed messages.")
415-
@rstudio_args
415+
@cloud_shinyapps_args
416416
@click.pass_context
417417
def add(ctx, name, server, api_key, insecure, cacert, account, token, secret, verbose):
418418

@@ -961,7 +961,7 @@ def deploy_voila(
961961
)
962962
@server_args
963963
@content_args
964-
@rstudio_args
964+
@cloud_shinyapps_args
965965
@click.argument("file", type=click.Path(exists=True, dir_okay=True, file_okay=True))
966966
@cli_exception_handler
967967
def deploy_manifest(
@@ -1133,12 +1133,13 @@ def deploy_quarto(
11331133
# noinspection SpellCheckingInspection,DuplicatedCode
11341134
@deploy.command(
11351135
name="html",
1136-
short_help="Deploy html content to Posit Connect.",
1137-
help=("Deploy an html file, or directory of html files with entrypoint, to Posit Connect."),
1136+
short_help="Deploy html content to Posit Connect or Posit Cloud.",
1137+
help=("Deploy an html file, or directory of html files with entrypoint, to Posit Connect or Posit Cloud."),
11381138
no_args_is_help=True,
11391139
)
11401140
@server_args
11411141
@content_args
1142+
@cloud_shinyapps_args
11421143
@click.option(
11431144
"--entrypoint",
11441145
"-e",
@@ -1177,6 +1178,9 @@ def deploy_html(
11771178
api_key: str = None,
11781179
insecure: bool = False,
11791180
cacert: typing.IO = None,
1181+
account: str = None,
1182+
token: str = None,
1183+
secret: str = None,
11801184
):
11811185
kwargs = locals()
11821186
ce = None
@@ -1218,7 +1222,7 @@ def generate_deploy_python(app_mode, alias, min_version):
12181222
)
12191223
@server_args
12201224
@content_args
1221-
@rstudio_args
1225+
@cloud_shinyapps_args
12221226
@click.option(
12231227
"--entrypoint",
12241228
"-e",

rsconnect/metadata.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -396,19 +396,23 @@ class AppStore(DataStore):
396396
* App GUID
397397
* Title
398398
* App mode
399+
* App store file version
399400
400401
The metadata file for an app is written in the same directory as the app's
401402
entry point file, if that directory is writable. Otherwise, it is stored
402403
in the user's config directory under `applications/{hash}.json` where the
403-
hash is derived from the entry point file name.
404+
hash is derived from the entry point file name. The file contains a version
405+
field, which is incremented when backwards-incompatible file format changes
406+
are made.
404407
"""
405408

406-
def __init__(self, app_file):
409+
def __init__(self, app_file, version=1):
407410
base_name = str(basename(app_file).rsplit(".", 1)[0]) + ".json"
408411
super(AppStore, self).__init__(
409412
join(dirname(app_file), "rsconnect-python", base_name),
410413
join(config_dirname(), "applications", sha1(abspath(app_file)) + ".json"),
411414
)
415+
self.version = version
412416

413417
def get(self, server_url):
414418
"""
@@ -446,14 +450,15 @@ def set(self, server_url, filename, app_url, app_id, app_guid, title, app_mode):
446450
app_guid=app_guid,
447451
title=title,
448452
app_mode=app_mode.name() if isinstance(app_mode, AppMode) else app_mode,
453+
app_store_version=self.version,
449454
),
450455
)
451456

452457
def resolve(self, server, app_id, app_mode):
453458
metadata = self.get(server)
454459
if metadata is None:
455460
logger.debug("No previous deployment to this server was found; this will be a new deployment.")
456-
return app_id, app_mode
461+
return app_id, app_mode, self.version
457462

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

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

464469
# app mode cannot be changed on redeployment
465470
app_mode = AppModes.get_by_name(metadata.get("app_mode"))
466-
return app_id, app_mode
471+
472+
app_store_version = metadata.get("app_store_version")
473+
return app_id, app_mode, app_store_version
467474

468475

469476
DEFAULT_BUILD_DIR = join(os.getcwd(), "rsconnect-build")

tests/test_api.py

Lines changed: 192 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,20 @@
66
import sys
77
import io
88
from rsconnect.exception import RSConnectException
9+
from rsconnect.models import AppModes
910
from .utils import (
1011
require_api_key,
1112
require_connect,
1213
)
13-
from rsconnect.api import RSConnectClient, RSConnectExecutor, RSConnectServer, _to_server_check_list
14+
from rsconnect.api import (
15+
RSConnectClient,
16+
RSConnectExecutor,
17+
RSConnectServer,
18+
_to_server_check_list,
19+
CloudService,
20+
PositClient,
21+
CloudServer,
22+
)
1423

1524

1625
class TestAPI(TestCase):
@@ -207,3 +216,185 @@ def test_deploy_existing_application_with_failure(self):
207216
app_id = Mock()
208217
with self.assertRaises(RSConnectException):
209218
client.deploy(app_id, app_name=None, app_title=None, title_is_default=None, tarball=None)
219+
220+
221+
class CloudServiceTestCase(TestCase):
222+
def test_prepare_new_deploy(self):
223+
cloud_client = Mock(spec=PositClient)
224+
server = CloudServer("https://api.posit.cloud", "the_account", "the_token", "the_secret")
225+
project_application_id = "20"
226+
cloud_service = CloudService(
227+
cloud_client=cloud_client, server=server, project_application_id=project_application_id
228+
)
229+
230+
app_id = None
231+
app_name = "my app"
232+
bundle_size = 5000
233+
bundle_hash = "the_hash"
234+
app_mode = AppModes.PYTHON_SHINY
235+
236+
cloud_client.get_application.return_value = {
237+
"content_id": 2,
238+
}
239+
cloud_client.get_content.return_value = {
240+
"space_id": 1000,
241+
}
242+
cloud_client.create_output.return_value = {
243+
"id": 1,
244+
"source_id": 10,
245+
"url": "https://posit.cloud/content/1",
246+
}
247+
cloud_client.create_bundle.return_value = {
248+
"id": 100,
249+
"presigned_url": "https://presigned.url",
250+
"presigned_checksum": "the_checksum",
251+
}
252+
253+
prepare_deploy_result = cloud_service.prepare_deploy(
254+
app_id=app_id,
255+
app_name=app_name,
256+
bundle_size=bundle_size,
257+
bundle_hash=bundle_hash,
258+
app_mode=app_mode,
259+
app_store_version=1,
260+
)
261+
262+
cloud_client.get_application.assert_called_with(project_application_id)
263+
cloud_client.get_content.assert_called_with(2)
264+
cloud_client.create_output.assert_called_with(
265+
name=app_name, application_type="connect", project_id=2, space_id=1000
266+
)
267+
cloud_client.create_bundle.assert_called_with(10, "application/x-tar", bundle_size, bundle_hash)
268+
269+
assert prepare_deploy_result.app_id == 1
270+
assert prepare_deploy_result.application_id == 10
271+
assert prepare_deploy_result.app_url == "https://posit.cloud/content/1"
272+
assert prepare_deploy_result.bundle_id == 100
273+
assert prepare_deploy_result.presigned_url == "https://presigned.url"
274+
assert prepare_deploy_result.presigned_checksum == "the_checksum"
275+
276+
def test_prepare_redeploy(self):
277+
cloud_client = Mock(spec=PositClient)
278+
server = CloudServer("https://api.posit.cloud", "the_account", "the_token", "the_secret")
279+
project_application_id = "20"
280+
cloud_service = CloudService(
281+
cloud_client=cloud_client, server=server, project_application_id=project_application_id
282+
)
283+
284+
app_id = 1
285+
app_name = "my app"
286+
bundle_size = 5000
287+
bundle_hash = "the_hash"
288+
app_mode = AppModes.PYTHON_SHINY
289+
290+
cloud_client.get_content.return_value = {"id": 1, "source_id": 10, "url": "https://posit.cloud/content/1"}
291+
cloud_client.create_bundle.return_value = {
292+
"id": 100,
293+
"presigned_url": "https://presigned.url",
294+
"presigned_checksum": "the_checksum",
295+
}
296+
297+
prepare_deploy_result = cloud_service.prepare_deploy(
298+
app_id=app_id,
299+
app_name=app_name,
300+
bundle_size=bundle_size,
301+
bundle_hash=bundle_hash,
302+
app_mode=app_mode,
303+
app_store_version=1,
304+
)
305+
cloud_client.get_content.assert_called_with(1)
306+
cloud_client.create_bundle.assert_called_with(10, "application/x-tar", bundle_size, bundle_hash)
307+
308+
assert prepare_deploy_result.app_id == 1
309+
assert prepare_deploy_result.application_id == 10
310+
assert prepare_deploy_result.app_url == "https://posit.cloud/content/1"
311+
assert prepare_deploy_result.bundle_id == 100
312+
assert prepare_deploy_result.presigned_url == "https://presigned.url"
313+
assert prepare_deploy_result.presigned_checksum == "the_checksum"
314+
315+
def test_prepare_redeploy_static(self):
316+
cloud_client = Mock(spec=PositClient)
317+
server = CloudServer("https://api.posit.cloud", "the_account", "the_token", "the_secret")
318+
project_application_id = "20"
319+
cloud_service = CloudService(
320+
cloud_client=cloud_client, server=server, project_application_id=project_application_id
321+
)
322+
323+
app_id = 1
324+
app_name = "my app"
325+
bundle_size = 5000
326+
bundle_hash = "the_hash"
327+
app_mode = AppModes.STATIC
328+
329+
cloud_client.get_content.return_value = {"id": 1, "source_id": 10, "url": "https://posit.cloud/content/1"}
330+
cloud_client.create_revision.return_value = {
331+
"application_id": 11,
332+
}
333+
cloud_client.create_bundle.return_value = {
334+
"id": 100,
335+
"presigned_url": "https://presigned.url",
336+
"presigned_checksum": "the_checksum",
337+
}
338+
339+
prepare_deploy_result = cloud_service.prepare_deploy(
340+
app_id=app_id,
341+
app_name=app_name,
342+
bundle_size=bundle_size,
343+
bundle_hash=bundle_hash,
344+
app_mode=app_mode,
345+
app_store_version=1,
346+
)
347+
cloud_client.get_content.assert_called_with(1)
348+
cloud_client.create_revision.assert_called_with(1)
349+
cloud_client.create_bundle.assert_called_with(11, "application/x-tar", bundle_size, bundle_hash)
350+
351+
assert prepare_deploy_result.app_id == 1
352+
assert prepare_deploy_result.application_id == 11
353+
assert prepare_deploy_result.app_url == "https://posit.cloud/content/1"
354+
assert prepare_deploy_result.bundle_id == 100
355+
assert prepare_deploy_result.presigned_url == "https://presigned.url"
356+
assert prepare_deploy_result.presigned_checksum == "the_checksum"
357+
358+
def test_prepare_redeploy_preversioned_app_store(self):
359+
cloud_client = Mock(spec=PositClient)
360+
server = CloudServer("https://api.posit.cloud", "the_account", "the_token", "the_secret")
361+
project_application_id = "20"
362+
cloud_service = CloudService(
363+
cloud_client=cloud_client, server=server, project_application_id=project_application_id
364+
)
365+
366+
app_id = 10
367+
app_name = "my app"
368+
bundle_size = 5000
369+
bundle_hash = "the_hash"
370+
app_mode = AppModes.PYTHON_SHINY
371+
372+
cloud_client.get_application.return_value = {
373+
"id": 10,
374+
"content_id": 1,
375+
}
376+
cloud_client.get_content.return_value = {"id": 1, "source_id": 10, "url": "https://posit.cloud/content/1"}
377+
cloud_client.create_bundle.return_value = {
378+
"id": 100,
379+
"presigned_url": "https://presigned.url",
380+
"presigned_checksum": "the_checksum",
381+
}
382+
383+
prepare_deploy_result = cloud_service.prepare_deploy(
384+
app_id=app_id,
385+
app_name=app_name,
386+
bundle_size=bundle_size,
387+
bundle_hash=bundle_hash,
388+
app_mode=app_mode,
389+
app_store_version=None,
390+
)
391+
cloud_client.get_application.assert_called_with(10)
392+
cloud_client.get_content.assert_called_with(1)
393+
cloud_client.create_bundle.assert_called_with(10, "application/x-tar", bundle_size, bundle_hash)
394+
395+
assert prepare_deploy_result.app_id == 1
396+
assert prepare_deploy_result.application_id == 10
397+
assert prepare_deploy_result.app_url == "https://posit.cloud/content/1"
398+
assert prepare_deploy_result.bundle_id == 100
399+
assert prepare_deploy_result.presigned_url == "https://presigned.url"
400+
assert prepare_deploy_result.presigned_checksum == "the_checksum"

0 commit comments

Comments
 (0)