Skip to content

Commit

Permalink
Add support for deploying quarto files/projects to posit.cloud (#444)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
omar-rs authored Jul 27, 2023
1 parent 22dac9c commit d3e4ad7
Show file tree
Hide file tree
Showing 6 changed files with 426 additions and 65 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- 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).
- Deploys for Posit Cloud now support Quarto source files or projects with `markdown` or `jupyter` engines.


## [1.18.0] - 2023-06-27

Expand Down
28 changes: 22 additions & 6 deletions rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1120,8 +1120,15 @@ def create_application(self, account_id, application_name):
self._server.handle_bad_response(response)
return response

def create_output(self, name: str, application_type: str, project_id=None, space_id=None):
data = {"name": name, "space": space_id, "project": project_id, "application_type": application_type}
def create_output(self, name: str, application_type: str, project_id=None, space_id=None, render_by=None):
data = {
"name": name,
"space": space_id,
"project": project_id,
"application_type": application_type
}
if render_by:
data['render_by'] = render_by
response = self.post("/v1/outputs/", body=data)
self._server.handle_bad_response(response)
return response
Expand Down Expand Up @@ -1334,7 +1341,14 @@ def prepare_deploy(
app_mode: AppMode,
app_store_version: typing.Optional[int],
) -> PrepareDeployOutputResult:
application_type = "static" if app_mode == AppModes.STATIC else "connect"

application_type = "static" if app_mode in [
AppModes.STATIC,
AppModes.STATIC_QUARTO] else "connect"
logger.debug(f"application_type: {application_type}")

render_by = "server" if app_mode == AppModes.STATIC_QUARTO else None
logger.debug(f"render_by: {render_by}")

project_id = self._get_current_project_id()

Expand All @@ -1348,9 +1362,11 @@ def prepare_deploy(
space_id = None

# create the new output and associate it with the current Posit Cloud project and space
output = self._rstudio_client.create_output(
name=app_name, application_type=application_type, project_id=project_id, space_id=space_id
)
output = self._rstudio_client.create_output(name=app_name,
application_type=application_type,
project_id=project_id,
space_id=space_id,
render_by=render_by)
app_id_int = output["source_id"]
else:
# this is a redeployment of an existing output
Expand Down
27 changes: 17 additions & 10 deletions rsconnect/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ def bundle_add_file(bundle, rel_path, base_dir):
The file path is relative to the notebook directory.
"""
path = join(base_dir, rel_path) if os.path.isdir(base_dir) else rel_path
logger.debug("adding file: %s", rel_path)
logger.debug("adding file: %s", path)
bundle.add(path, arcname=rel_path)


Expand Down Expand Up @@ -580,7 +580,7 @@ def make_quarto_source_bundle(

base_dir = file_or_directory
if not isdir(file_or_directory):
base_dir = basename(file_or_directory)
base_dir = dirname(file_or_directory)

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

return sorted(file_set)


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

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

# relevant files don't need to include requirements.txt file because it is
# always added to the manifest (as a buffer) from the environment contents
if environment:
excludes.append(environment.filename)

relevant_files = _create_quarto_file_list(base_dir, extra_files, excludes)
else:
# Standalone Quarto document
base_dir = dirname(file_or_directory)
relevant_files = [file_or_directory] + extra_files
file_name = basename(file_or_directory)
relevant_files = [file_name] + extra_files

manifest = make_source_manifest(
app_mode,
Expand All @@ -1229,6 +1233,9 @@ def make_quarto_manifest(
image,
)

if environment:
manifest_add_buffer(manifest, environment.filename, environment.contents)

for rel_path in relevant_files:
manifest_add_file(manifest, rel_path, base_dir)

Expand Down
14 changes: 7 additions & 7 deletions rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -965,7 +965,7 @@ def deploy_voila(
name="manifest",
short_help="Deploy content to Posit Connect, Posit Cloud, or shinyapps.io by manifest.",
help=(
"Deploy content to Posit Connect using an existing manifest.json "
"Deploy content to Posit Connect, Posit Cloud, or shinyapps.io using an existing manifest.json "
'file. The specified file must either be named "manifest.json" or '
'refer to a directory that contains a file named "manifest.json".'
),
Expand Down Expand Up @@ -1018,13 +1018,13 @@ def deploy_manifest(
# noinspection SpellCheckingInspection,DuplicatedCode
@deploy.command(
name="quarto",
short_help="Deploy Quarto content to Posit Connect [v2021.08.0+].",
short_help="Deploy Quarto content to Posit Connect [v2021.08.0+] or Posit Cloud.",
help=(
"Deploy a Quarto document or project to Posit Connect. Should the content use the Quarto Jupyter engine, "
'an environment file ("requirements.txt") is created and included in the deployment if one does '
"not already exist. Requires Posit Connect 2021.08.0 or later."
"\n\n"
"FILE_OR_DIRECTORY is the path to a single-file Quarto document or the directory containing a Quarto project."
'Deploy a Quarto document or project to Posit Connect or Posit Cloud. Should the content use the Quarto '
'Jupyter engine, an environment file ("requirements.txt") is created and included in the deployment if one '
'does not already exist. Requires Posit Connect 2021.08.0 or later.'
'\n\n'
'FILE_OR_DIRECTORY is the path to a single-file Quarto document or the directory containing a Quarto project.'
),
no_args_is_help=True,
)
Expand Down
51 changes: 48 additions & 3 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,6 @@ def test_do_deploy_failure(self):


class CloudServiceTestCase(TestCase):

def setUp(self):
self.cloud_client = Mock(spec=PositClient)
self.server = CloudServer("https://api.posit.cloud", "the_account", "the_token", "the_secret")
Expand All @@ -277,7 +276,7 @@ def setUp(self):
cloud_client=self.cloud_client, server=self.server, project_application_id=self.project_application_id
)

def test_prepare_new_deploy(self):
def test_prepare_new_deploy_python_shiny(self):
app_id = None
app_name = "my app"
bundle_size = 5000
Expand Down Expand Up @@ -313,7 +312,7 @@ def test_prepare_new_deploy(self):
self.cloud_client.get_application.assert_called_with(self.project_application_id)
self.cloud_client.get_content.assert_called_with(2)
self.cloud_client.create_output.assert_called_with(
name=app_name, application_type="connect", project_id=2, space_id=1000
name=app_name, application_type="connect", project_id=2, space_id=1000, render_by=None
)
self.cloud_client.create_bundle.assert_called_with(10, "application/x-tar", bundle_size, bundle_hash)

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

def test_prepare_new_deploy_static_quarto(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.STATIC_QUARTO

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",
}

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="static", project_id=2, space_id=1000, render_by='server'
)

def test_prepare_redeploy(self):
app_id = 1
app_name = "my app"
Expand Down
Loading

0 comments on commit d3e4ad7

Please sign in to comment.