Skip to content

Commit d3e4ad7

Browse files
authored
Add support for deploying quarto files/projects to posit.cloud (#444)
* Add support for deploying quarto files/projects to posit.cloud * remove unused * fix test * Handle the addition of the package requirements regardless of how they are sourced
1 parent 22dac9c commit d3e4ad7

File tree

6 files changed

+426
-65
lines changed

6 files changed

+426
-65
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
### Added
1616

1717
- The `CONNECT_TASK_TIMEOUT` environment variable, which configures the timeout for [task based operations](https://docs.posit.co/connect/api/#get-/v1/tasks/-id-). This value translates into seconds (e.g., `CONNECT_TASK_TIMEOUT=60` is equivalent to 60 seconds.) By default, this value is set to 86,400 seconds (e.g., 24 hours).
18+
- Deploys for Posit Cloud now support Quarto source files or projects with `markdown` or `jupyter` engines.
19+
1820

1921
## [1.18.0] - 2023-06-27
2022

rsconnect/api.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,8 +1120,15 @@ def create_application(self, account_id, application_name):
11201120
self._server.handle_bad_response(response)
11211121
return response
11221122

1123-
def create_output(self, name: str, application_type: str, project_id=None, space_id=None):
1124-
data = {"name": name, "space": space_id, "project": project_id, "application_type": application_type}
1123+
def create_output(self, name: str, application_type: str, project_id=None, space_id=None, render_by=None):
1124+
data = {
1125+
"name": name,
1126+
"space": space_id,
1127+
"project": project_id,
1128+
"application_type": application_type
1129+
}
1130+
if render_by:
1131+
data['render_by'] = render_by
11251132
response = self.post("/v1/outputs/", body=data)
11261133
self._server.handle_bad_response(response)
11271134
return response
@@ -1334,7 +1341,14 @@ def prepare_deploy(
13341341
app_mode: AppMode,
13351342
app_store_version: typing.Optional[int],
13361343
) -> PrepareDeployOutputResult:
1337-
application_type = "static" if app_mode == AppModes.STATIC else "connect"
1344+
1345+
application_type = "static" if app_mode in [
1346+
AppModes.STATIC,
1347+
AppModes.STATIC_QUARTO] else "connect"
1348+
logger.debug(f"application_type: {application_type}")
1349+
1350+
render_by = "server" if app_mode == AppModes.STATIC_QUARTO else None
1351+
logger.debug(f"render_by: {render_by}")
13381352

13391353
project_id = self._get_current_project_id()
13401354

@@ -1348,9 +1362,11 @@ def prepare_deploy(
13481362
space_id = None
13491363

13501364
# create the new output and associate it with the current Posit Cloud project and space
1351-
output = self._rstudio_client.create_output(
1352-
name=app_name, application_type=application_type, project_id=project_id, space_id=space_id
1353-
)
1365+
output = self._rstudio_client.create_output(name=app_name,
1366+
application_type=application_type,
1367+
project_id=project_id,
1368+
space_id=space_id,
1369+
render_by=render_by)
13541370
app_id_int = output["source_id"]
13551371
else:
13561372
# this is a redeployment of an existing output

rsconnect/bundle.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ def bundle_add_file(bundle, rel_path, base_dir):
411411
The file path is relative to the notebook directory.
412412
"""
413413
path = join(base_dir, rel_path) if os.path.isdir(base_dir) else rel_path
414-
logger.debug("adding file: %s", rel_path)
414+
logger.debug("adding file: %s", path)
415415
bundle.add(path, arcname=rel_path)
416416

417417

@@ -580,7 +580,7 @@ def make_quarto_source_bundle(
580580

581581
base_dir = file_or_directory
582582
if not isdir(file_or_directory):
583-
base_dir = basename(file_or_directory)
583+
base_dir = dirname(file_or_directory)
584584

585585
with tarfile.open(mode="w:gz", fileobj=bundle_file) as bundle:
586586
bundle_add_buffer(bundle, "manifest.json", json.dumps(manifest, indent=2))
@@ -851,7 +851,7 @@ def create_html_manifest(
851851
Creates and writes a manifest.json file for the given path.
852852
853853
:param path: the file, or the directory containing the files to deploy.
854-
:param entry_point: the main entry point for the API.
854+
:param entrypoint: the main entry point for the API.
855855
:param environment: the Python environment to start with. This should be what's
856856
returned by the inspect_environment() function.
857857
:param app_mode: the application mode to assume. If this is None, the extension
@@ -908,14 +908,11 @@ def make_html_bundle(
908908
Create an html bundle, given a path and/or entrypoint.
909909
910910
The bundle contains a manifest.json file created for the given notebook entrypoint file.
911-
If the related environment file (requirements.txt) doesn't
912-
exist (or force_generate is set to True), the environment file will also be written.
913911
914912
:param path: the file, or the directory containing the files to deploy.
915-
:param entry_point: the main entry point.
913+
:param entrypoint: the main entry point.
916914
:param extra_files: a sequence of any extra files to include in the bundle.
917915
:param excludes: a sequence of glob patterns that will exclude matched files.
918-
:param force_generate: bool indicating whether to force generate manifest and related environment files.
919916
:param image: the optional docker image to be specified for off-host execution. Default = None.
920917
:return: a file-like object containing the bundle tarball.
921918
"""
@@ -982,6 +979,7 @@ def create_file_list(
982979
):
983980
path_to_add = abspath(cur_path) if use_abspath else rel_path
984981
file_set.add(path_to_add)
982+
985983
return sorted(file_set)
986984

987985

@@ -1077,7 +1075,7 @@ def make_voila_bundle(
10771075
exist (or force_generate is set to True), the environment file will also be written.
10781076
10791077
:param path: the file, or the directory containing the files to deploy.
1080-
:param entry_point: the main entry point.
1078+
:param entrypoint: the main entry point.
10811079
:param extra_files: a sequence of any extra files to include in the bundle.
10821080
:param excludes: a sequence of glob patterns that will exclude matched files.
10831081
:param force_generate: bool indicating whether to force generate manifest and related environment files.
@@ -1196,7 +1194,7 @@ def make_quarto_manifest(
11961194
:return: the manifest and a list of the files involved.
11971195
"""
11981196
if environment:
1199-
extra_files = list(extra_files or []) + [environment.filename]
1197+
extra_files = list(extra_files or [])
12001198

12011199
base_dir = file_or_directory
12021200
if isdir(file_or_directory):
@@ -1215,11 +1213,17 @@ def make_quarto_manifest(
12151213
# For foo.qmd, we would get an output-file=foo.html, but foo_files is not available.
12161214
excludes = excludes + [t + ".html", t + "_files"]
12171215

1216+
# relevant files don't need to include requirements.txt file because it is
1217+
# always added to the manifest (as a buffer) from the environment contents
1218+
if environment:
1219+
excludes.append(environment.filename)
1220+
12181221
relevant_files = _create_quarto_file_list(base_dir, extra_files, excludes)
12191222
else:
12201223
# Standalone Quarto document
12211224
base_dir = dirname(file_or_directory)
1222-
relevant_files = [file_or_directory] + extra_files
1225+
file_name = basename(file_or_directory)
1226+
relevant_files = [file_name] + extra_files
12231227

12241228
manifest = make_source_manifest(
12251229
app_mode,
@@ -1229,6 +1233,9 @@ def make_quarto_manifest(
12291233
image,
12301234
)
12311235

1236+
if environment:
1237+
manifest_add_buffer(manifest, environment.filename, environment.contents)
1238+
12321239
for rel_path in relevant_files:
12331240
manifest_add_file(manifest, rel_path, base_dir)
12341241

rsconnect/main.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -965,7 +965,7 @@ def deploy_voila(
965965
name="manifest",
966966
short_help="Deploy content to Posit Connect, Posit Cloud, or shinyapps.io by manifest.",
967967
help=(
968-
"Deploy content to Posit Connect using an existing manifest.json "
968+
"Deploy content to Posit Connect, Posit Cloud, or shinyapps.io using an existing manifest.json "
969969
'file. The specified file must either be named "manifest.json" or '
970970
'refer to a directory that contains a file named "manifest.json".'
971971
),
@@ -1018,13 +1018,13 @@ def deploy_manifest(
10181018
# noinspection SpellCheckingInspection,DuplicatedCode
10191019
@deploy.command(
10201020
name="quarto",
1021-
short_help="Deploy Quarto content to Posit Connect [v2021.08.0+].",
1021+
short_help="Deploy Quarto content to Posit Connect [v2021.08.0+] or Posit Cloud.",
10221022
help=(
1023-
"Deploy a Quarto document or project to Posit Connect. Should the content use the Quarto Jupyter engine, "
1024-
'an environment file ("requirements.txt") is created and included in the deployment if one does '
1025-
"not already exist. Requires Posit Connect 2021.08.0 or later."
1026-
"\n\n"
1027-
"FILE_OR_DIRECTORY is the path to a single-file Quarto document or the directory containing a Quarto project."
1023+
'Deploy a Quarto document or project to Posit Connect or Posit Cloud. Should the content use the Quarto '
1024+
'Jupyter engine, an environment file ("requirements.txt") is created and included in the deployment if one '
1025+
'does not already exist. Requires Posit Connect 2021.08.0 or later.'
1026+
'\n\n'
1027+
'FILE_OR_DIRECTORY is the path to a single-file Quarto document or the directory containing a Quarto project.'
10281028
),
10291029
no_args_is_help=True,
10301030
)

tests/test_api.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,6 @@ def test_do_deploy_failure(self):
268268

269269

270270
class CloudServiceTestCase(TestCase):
271-
272271
def setUp(self):
273272
self.cloud_client = Mock(spec=PositClient)
274273
self.server = CloudServer("https://api.posit.cloud", "the_account", "the_token", "the_secret")
@@ -277,7 +276,7 @@ def setUp(self):
277276
cloud_client=self.cloud_client, server=self.server, project_application_id=self.project_application_id
278277
)
279278

280-
def test_prepare_new_deploy(self):
279+
def test_prepare_new_deploy_python_shiny(self):
281280
app_id = None
282281
app_name = "my app"
283282
bundle_size = 5000
@@ -313,7 +312,7 @@ def test_prepare_new_deploy(self):
313312
self.cloud_client.get_application.assert_called_with(self.project_application_id)
314313
self.cloud_client.get_content.assert_called_with(2)
315314
self.cloud_client.create_output.assert_called_with(
316-
name=app_name, application_type="connect", project_id=2, space_id=1000
315+
name=app_name, application_type="connect", project_id=2, space_id=1000, render_by=None
317316
)
318317
self.cloud_client.create_bundle.assert_called_with(10, "application/x-tar", bundle_size, bundle_hash)
319318

@@ -324,6 +323,52 @@ def test_prepare_new_deploy(self):
324323
assert prepare_deploy_result.presigned_url == "https://presigned.url"
325324
assert prepare_deploy_result.presigned_checksum == "the_checksum"
326325

326+
def test_prepare_new_deploy_static_quarto(self):
327+
cloud_client = Mock(spec=PositClient)
328+
server = CloudServer("https://api.posit.cloud", "the_account", "the_token", "the_secret")
329+
project_application_id = "20"
330+
cloud_service = CloudService(
331+
cloud_client=cloud_client, server=server, project_application_id=project_application_id
332+
)
333+
334+
app_id = None
335+
app_name = "my app"
336+
bundle_size = 5000
337+
bundle_hash = "the_hash"
338+
app_mode = AppModes.STATIC_QUARTO
339+
340+
cloud_client.get_application.return_value = {
341+
"content_id": 2,
342+
}
343+
cloud_client.get_content.return_value = {
344+
"space_id": 1000,
345+
}
346+
cloud_client.create_output.return_value = {
347+
"id": 1,
348+
"source_id": 10,
349+
"url": "https://posit.cloud/content/1",
350+
}
351+
cloud_client.create_bundle.return_value = {
352+
"id": 100,
353+
"presigned_url": "https://presigned.url",
354+
"presigned_checksum": "the_checksum",
355+
}
356+
357+
cloud_service.prepare_deploy(
358+
app_id=app_id,
359+
app_name=app_name,
360+
bundle_size=bundle_size,
361+
bundle_hash=bundle_hash,
362+
app_mode=app_mode,
363+
app_store_version=1,
364+
)
365+
366+
cloud_client.get_application.assert_called_with(project_application_id)
367+
cloud_client.get_content.assert_called_with(2)
368+
cloud_client.create_output.assert_called_with(
369+
name=app_name, application_type="static", project_id=2, space_id=1000, render_by='server'
370+
)
371+
327372
def test_prepare_redeploy(self):
328373
app_id = 1
329374
app_name = "my app"

0 commit comments

Comments
 (0)