diff --git a/CHANGELOG.md b/CHANGELOG.md index 0faf2386..ba7c2e89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. 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 + +- You can now deploy Quarto documents in addition to Quarto projects. This + requires RStudio Connect release 2021.08.0 or later. Use `rsconnect deploy + quarto` to deploy, or `rsconnect write-manifest quarto` to create a manifest + file. + ## [1.8.1] - 2022-05-31 ### Changed diff --git a/rsconnect/actions.py b/rsconnect/actions.py index 4ef45d79..4ddfe3ce 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -505,7 +505,11 @@ def quarto_inspect( """ Runs 'quarto inspect' against the target and returns its output as a parsed JSON object. + + The JSON result has different structure depending on whether or not the + target is a directory or a file. """ + args = [quarto, "inspect", target] try: inspect_json = check_output(args, universal_newlines=True, stderr=subprocess.STDOUT) @@ -527,7 +531,7 @@ def validate_quarto_engines(inspect): def write_quarto_manifest_json( - directory: str, + file_or_directory: str, inspect: typing.Any, app_mode: AppMode, environment: Environment, @@ -538,7 +542,7 @@ def write_quarto_manifest_json( """ Creates and writes a manifest.json file for the given Quarto project. - :param directory: The directory containing the Quarto project. + :param file_or_directory: The Quarto document or the directory containing the Quarto project. :param inspect: The parsed JSON from a 'quarto inspect' against the project. :param app_mode: The application mode to assume (such as AppModes.STATIC_QUARTO) :param environment: The (optional) Python environment to use. @@ -547,10 +551,20 @@ def write_quarto_manifest_json( :param image: the optional docker image to be specified for off-host execution. Default = None. """ - extra_files = validate_extra_files(directory, extra_files) - manifest, _ = make_quarto_manifest(directory, inspect, app_mode, environment, extra_files, excludes, image) - manifest_path = join(directory, "manifest.json") + manifest, _ = make_quarto_manifest( + file_or_directory, + inspect, + app_mode, + environment, + extra_files, + excludes, + image, + ) + base_dir = file_or_directory + if not isdir(file_or_directory): + base_dir = dirname(file_or_directory) + manifest_path = join(base_dir, "manifest.json") write_manifest_json(manifest_path, manifest) @@ -1307,7 +1321,7 @@ def gather_basic_deployment_info_from_manifest( def gather_basic_deployment_info_for_quarto( connect_server: api.RSConnectServer, app_store: AppStore, - directory: str, + file_or_directory: str, new: bool, app_id: int, title: str, @@ -1317,7 +1331,7 @@ def gather_basic_deployment_info_for_quarto( :param connect_server: The Connect server information. :param app_store: The store for the specified Quarto project directory. - :param directory: The target Quarto project directory. + :param file_or_directory: The Quarto document or directory containing the Quarto project. :param new: A flag to force a new deployment. :param app_id: The identifier of the content to redeploy. :param title: The content title (optional). A default title is generated when one is not provided. @@ -1349,11 +1363,11 @@ def gather_basic_deployment_info_for_quarto( ) % (app_mode.desc(), existing_app_mode.desc()) raise api.RSConnectException(msg) - if directory[-1] == "/": - directory = directory[:-1] + if file_or_directory[-1] == "/": + file_or_directory = file_or_directory[:-1] default_title = not bool(title) - title = title or _default_title(directory) + title = title or _default_title(file_or_directory) return ( app_id, @@ -1589,19 +1603,18 @@ def create_api_deployment_bundle( def create_quarto_deployment_bundle( - directory: str, + file_or_directory: str, extra_files: typing.List[str], excludes: typing.List[str], app_mode: AppMode, inspect: typing.Dict[str, typing.Any], environment: Environment, - extra_files_need_validating: bool, image: str = None, ) -> typing.IO[bytes]: """ Create an in-memory bundle, ready to deploy. - :param directory: the directory that contains the code being deployed. + :param file_or_directory: The Quarto document or the directory containing the Quarto project. :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 entry_point: the module/executable object for the WSGi framework. @@ -1614,13 +1627,10 @@ def create_quarto_deployment_bundle( :param image: the optional docker image to be specified for off-host execution. Default = None. :return: the bundle. """ - if extra_files_need_validating: - extra_files = validate_extra_files(directory, extra_files) - if app_mode is None: app_mode = AppModes.STATIC_QUARTO - return make_quarto_source_bundle(directory, inspect, app_mode, environment, extra_files, excludes, image) + return make_quarto_source_bundle(file_or_directory, inspect, app_mode, environment, extra_files, excludes, image) def deploy_bundle( diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index d9977d7b..6e11c985 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -309,9 +309,8 @@ def make_notebook_source_bundle( return bundle_file -# def make_quarto_source_bundle( - directory: str, + file_or_directory: str, inspect: typing.Dict[str, typing.Any], app_mode: AppMode, environment: Environment, @@ -326,17 +325,21 @@ def make_quarto_source_bundle( Returns a file-like object containing the bundle tarball. """ manifest, relevant_files = make_quarto_manifest( - directory, inspect, app_mode, environment, extra_files, excludes, image + file_or_directory, inspect, app_mode, environment, extra_files, excludes, image ) bundle_file = tempfile.TemporaryFile(prefix="rsc_bundle") + base_dir = file_or_directory + if not isdir(file_or_directory): + base_dir = basename(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)) if environment: bundle_add_buffer(bundle, environment.filename, environment.contents) for rel_path in relevant_files: - bundle_add_file(bundle, rel_path, directory) + bundle_add_file(bundle, rel_path, base_dir) # rewind file pointer bundle_file.seek(0) @@ -804,7 +807,7 @@ def _create_quarto_file_list( def make_quarto_manifest( - directory: str, + file_or_directory: str, quarto_inspection: typing.Dict[str, typing.Any], app_mode: AppMode, environment: Environment, @@ -815,7 +818,7 @@ def make_quarto_manifest( """ Makes a manifest for a Quarto project. - :param directory: The directory containing the Quarto project. + :param file_or_directory: The Quarto document or the directory containing the Quarto project. :param quarto_inspection: The parsed JSON from a 'quarto inspect' against the project. :param app_mode: The application mode to assume. :param environment: The (optional) Python environment to use. @@ -827,21 +830,29 @@ def make_quarto_manifest( if environment: extra_files = list(extra_files or []) + [environment.filename] - excludes = list(excludes or []) + [".quarto"] + base_dir = file_or_directory + if isdir(file_or_directory): + # Directory as a Quarto project. + excludes = list(excludes or []) + [".quarto"] - project_config = quarto_inspection.get("config", {}).get("project", {}) - output_dir = project_config.get("output-dir", None) - if output_dir: - excludes = excludes + [output_dir] + project_config = quarto_inspection.get("config", {}).get("project", {}) + output_dir = project_config.get("output-dir", None) + if output_dir: + excludes = excludes + [output_dir] + else: + render_targets = project_config.get("render", []) + for target in render_targets: + t, _ = splitext(target) + # TODO: Single-file inspect would give inspect.formats.html.pandoc.output-file + # 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 = _create_quarto_file_list(base_dir, extra_files, excludes) else: - render_targets = project_config.get("render", []) - for target in render_targets: - t, _ = splitext(target) - # TODO: Single-file inspect would give inspect.formats.html.pandoc.output-file - # For foo.qmd, we would get an output-file=foo.html, but foo_files is not available. - excludes = excludes + [t + ".html", t + "_files"] + # Standalone Quarto document + base_dir = dirname(file_or_directory) + relevant_files = [file_or_directory] + extra_files - relevant_files = _create_quarto_file_list(directory, extra_files, excludes) manifest = make_source_manifest( app_mode, environment, @@ -851,6 +862,6 @@ def make_quarto_manifest( ) for rel_path in relevant_files: - manifest_add_file(manifest, rel_path, directory) + manifest_add_file(manifest, rel_path, base_dir) return manifest, relevant_files diff --git a/rsconnect/main.py b/rsconnect/main.py index 21263074..645da90d 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -868,7 +868,13 @@ def deploy_manifest( @deploy.command( name="quarto", short_help="Deploy Quarto content to RStudio Connect [v2021.08.0+].", - help="Deploy Quarto content to RStudio Connect.", + help=( + "Deploy a Quarto document or project to RStudio 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 RStudio 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." + ), ) @server_args @content_args @@ -909,7 +915,7 @@ def deploy_manifest( help="Target image to be used during content execution (only applicable if the RStudio Connect " "server is configured to use off-host execution)", ) -@click.argument("directory", type=click.Path(exists=True, dir_okay=True, file_okay=False)) +@click.argument("file_or_directory", type=click.Path(exists=True, dir_okay=True, file_okay=True)) @click.argument( "extra_files", nargs=-1, @@ -929,42 +935,46 @@ def deploy_quarto( python, force_generate, verbose, - directory, + file_or_directory, extra_files, env_vars, image, ): set_verbosity(verbose) + base_dir = file_or_directory + if not isdir(file_or_directory): + base_dir = dirname(file_or_directory) + with cli_feedback("Checking arguments"): - module_file = fake_module_file_from_directory(directory) + module_file = fake_module_file_from_directory(file_or_directory) app_store = AppStore(module_file) connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) - extra_files = validate_extra_files(directory, extra_files) + extra_files = validate_extra_files(base_dir, extra_files) (app_id, deployment_name, title, default_title, app_mode) = gather_basic_deployment_info_for_quarto( connect_server, app_store, - directory, + file_or_directory, new, app_id, title, ) - click.secho(' Deploying %s to server "%s"' % (directory, connect_server.url)) + click.secho(' Deploying %s to server "%s"' % (file_or_directory, connect_server.url)) - _warn_on_ignored_manifest(directory) + _warn_on_ignored_manifest(base_dir) with cli_feedback("Inspecting Quarto project"): quarto = which_quarto(quarto) logger.debug("Quarto: %s" % quarto) - inspect = quarto_inspect(quarto, directory) + inspect = quarto_inspect(quarto, file_or_directory) engines = validate_quarto_engines(inspect) python = None environment = None if "jupyter" in engines: - _warn_if_no_requirements_file(directory) - _warn_if_environment_directory(directory) + _warn_if_no_requirements_file(base_dir) + _warn_if_environment_directory(base_dir) with cli_feedback("Inspecting Python environment"): python, environment = get_python_env_info(module_file, python, False, force_generate) @@ -972,17 +982,17 @@ def deploy_quarto( _warn_on_ignored_conda_env(environment) if force_generate: - _warn_on_ignored_requirements(directory, environment.filename) + _warn_on_ignored_requirements(base_dir, environment.filename) with cli_feedback("Creating deployment bundle"): bundle = create_quarto_deployment_bundle( - directory, extra_files, exclude, app_mode, inspect, environment, False, image + file_or_directory, extra_files, exclude, app_mode, inspect, environment, image ) _deploy_bundle( connect_server, app_store, - directory, + file_or_directory, app_id, app_mode, deployment_name, @@ -1429,11 +1439,13 @@ def write_manifest_notebook( name="quarto", short_help="Create a manifest.json file for Quarto content.", help=( - "Create a manifest.json file for a Quarto project for later " + "Create a manifest.json file for a Quarto document or project for later " "deployment. Should the content use the Quarto Jupyter engine, " 'an environment file ("requirements.txt") is created if one does ' "not already exist. All files are created in the same directory " "as the project. Requires RStudio 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." ), ) @click.option("--overwrite", "-o", is_flag=True, help="Overwrite manifest.json, if it exists.") @@ -1473,7 +1485,7 @@ def write_manifest_notebook( help="Target image to be used during content execution (only applicable if the RStudio Connect " "server is configured to use off-host execution)", ) -@click.argument("directory", type=click.Path(exists=True, dir_okay=True, file_okay=False)) +@click.argument("file_or_directory", type=click.Path(exists=True, dir_okay=True, file_okay=True)) @click.argument( "extra_files", nargs=-1, @@ -1486,14 +1498,19 @@ def write_manifest_quarto( python, force_generate, verbose, - directory, + file_or_directory, extra_files, image: str = None, ): set_verbosity(verbose) + + base_dir = file_or_directory + if not isdir(file_or_directory): + base_dir = dirname(file_or_directory) + with cli_feedback("Checking arguments"): - extra_files = validate_extra_files(directory, extra_files) - manifest_path = join(directory, "manifest.json") + extra_files = validate_extra_files(base_dir, extra_files) + manifest_path = join(base_dir, "manifest.json") if exists(manifest_path) and not overwrite: raise api.RSConnectException("manifest.json already exists. Use --overwrite to overwrite.") @@ -1501,16 +1518,16 @@ def write_manifest_quarto( with cli_feedback("Inspecting Quarto project"): quarto = which_quarto(quarto) logger.debug("Quarto: %s" % quarto) - inspect = quarto_inspect(quarto, directory) + inspect = quarto_inspect(quarto, file_or_directory) engines = validate_quarto_engines(inspect) environment = None if "jupyter" in engines: with cli_feedback("Inspecting Python environment"): - python, environment = get_python_env_info(directory, python, False, force_generate) + python, environment = get_python_env_info(base_dir, python, False, force_generate) _warn_on_ignored_conda_env(environment) - environment_file_exists = exists(join(directory, environment.filename)) + environment_file_exists = exists(join(base_dir, environment.filename)) if environment_file_exists and not force_generate: click.secho( " Warning: %s already exists and will not be overwritten." % environment.filename, @@ -1518,11 +1535,11 @@ def write_manifest_quarto( ) else: with cli_feedback("Creating %s" % environment.filename): - write_environment_file(environment, directory) + write_environment_file(environment, base_dir) with cli_feedback("Creating manifest.json"): write_quarto_manifest_json( - directory, + file_or_directory, inspect, AppModes.STATIC_QUARTO, environment,