diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e78d2f54..4f2b3cee 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -20,6 +20,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- R dependency detection from an `renv.lock` file. When deploying Python content + that also uses R (e.g. `rpy2`), rsconnect-python reads the lockfile and adds the + R version and packages to the manifest so Posit Connect can restore the R + library. The lockfile location honors the `RENV_PATHS_LOCKFILE` environment + variable. Use `--exclude-renv` to opt out. - `rsconnect quickstart` command for scaffolding a new Connect-ready project. Supported types: `streamlit`, `shiny`, `fastapi`, `api`, `flask`, `notebook`, `voila`, `quarto`. Creates a uv-managed virtualenv and prints diff --git a/docs/deploying.md b/docs/deploying.md index 7db7ba6f..0af64af9 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -237,6 +237,39 @@ use Python version 3.11.5. > The packages and package versions listed in `requirements.txt` must be > compatible with the Python version you request. +#### R Dependencies (renv.lock) + +Python content that also uses R (for example, an app that calls R through +[`rpy2`](https://rpy2.github.io/)) can declare its R package dependencies with an +[renv](https://rstudio.github.io/renv/) lockfile. When an `renv.lock` file is +found, rsconnect-python reads it and adds the R version and package list to the +generated `manifest.json`, so Posit Connect can restore the R library alongside +the Python environment. The lockfile is only parsed; R is never invoked. + +Detection happens automatically whenever a lockfile is present. To deploy without +it, use the `--exclude-renv` option: + +```bash +rsconnect deploy api --exclude-renv my-api/ +``` + +By default the lockfile is read from `renv.lock` in the content directory. The +location can be overridden with the `RENV_PATHS_LOCKFILE` environment variable, +matching renv's own resolution: an absolute path is used as given (a trailing +slash is treated as a directory, so `renv.lock` is appended), and a relative +path is resolved against the content directory. + +```bash +RENV_PATHS_LOCKFILE=/path/to/renv.lock rsconnect deploy api my-api/ +``` + +> **Note** +> The lockfile must be generated by renv 1.1.0 or later, so it records the +> repositories used to resolve each package. rsconnect-python stops with an error +> if the lockfile is incompatible or a package cannot be resolved; use +> `--exclude-renv` to deploy without R dependencies. renv profiles are not +> supported; only `RENV_PATHS_LOCKFILE` and the default location are consulted. + ### Creating a Manifest for Future Deployment You can create a `manifest.json` file for an API or application, then use that diff --git a/pyproject.toml b/pyproject.toml index f35ba49a..aa4ef4a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ test = [ "flake8", "httpretty", "ipykernel", + "mistune<3.3; python_version=='3.8'", # 3.3+ uses re.Pattern[str], invalid on 3.8 "nbconvert", "pyright", "pytest-cov", diff --git a/rsconnect/actions.py b/rsconnect/actions.py index a16e7359..3130e010 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -37,6 +37,7 @@ read_manifest_file, ) from .environment import Environment +from .environment_r import REnvironment from .exception import RSConnectException from .log import VERBOSE, logger from .models import AppMode, AppModes @@ -500,6 +501,7 @@ def create_quarto_deployment_bundle( image: Optional[str] = None, env_management_py: Optional[bool] = None, env_management_r: Optional[bool] = None, + r_environment: Optional[REnvironment] = None, ) -> typing.IO[bytes]: """ Create an in-memory bundle, ready to deploy. @@ -519,6 +521,7 @@ def create_quarto_deployment_bundle( The server administrator is responsible for installing packages in the runtime environment. Default = None. :param env_management_r: False prevents Connect from managing the R environment for this bundle. The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param r_environment: optional R dependencies detected from renv.lock to add to the manifest. :return: the bundle. """ if app_mode is None: @@ -534,6 +537,7 @@ def create_quarto_deployment_bundle( image, env_management_py, env_management_r, + r_environment, ) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 9e1d9b63..4a221157 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -54,6 +54,7 @@ from .environment import Environment, list_environment_dirs, is_environment_dir from .environment_node import NodeEnvironment +from .environment_r import REnvironment from .exception import RSConnectException from .log import VERBOSE, logger from .models import AppMode, AppModes, GlobSet @@ -142,6 +143,12 @@ class ManifestDataNode(TypedDict): package_manager: ManifestDataNodePackageManager +class ManifestDataRPackage(TypedDict): + Source: str + Repository: str + description: dict[str, str] + + class ManifestData(TypedDict): version: int files: dict[str, ManifestDataFile] @@ -151,6 +158,8 @@ class ManifestData(TypedDict): quarto: NotRequired[ManifestDataQuarto] python: NotRequired[ManifestDataPython] node: NotRequired[ManifestDataNode] + platform: NotRequired[str] + packages: NotRequired[dict[str, ManifestDataRPackage]] environment: NotRequired[ManifestDataEnvironment] @@ -159,6 +168,7 @@ def __init__( self, version: Optional[int] = None, environment: Optional[Environment] = None, + r_environment: Optional[REnvironment] = None, app_mode: Optional[AppMode] = None, entrypoint: Optional[str] = None, quarto_inspection: Optional[QuartoInspectResult] = None, @@ -224,6 +234,10 @@ def __init__( manifest_environment = self.data.setdefault("environment", {}) manifest_environment["python"] = {"requires": environment.python_version_requirement} + if r_environment: + self.data["platform"] = r_environment.r_version + self.data["packages"] = cast("dict[str, ManifestDataRPackage]", r_environment.packages) + if image or env_management_py is not None or env_management_r is not None: manifest_environment = self.data.setdefault("environment", {}) if image: @@ -410,10 +424,12 @@ def make_source_manifest( image: Optional[str] = None, env_management_py: Optional[bool] = None, env_management_r: Optional[bool] = None, + r_environment: Optional[REnvironment] = None, ) -> ManifestData: manifest: Manifest = Manifest( app_mode=app_mode, environment=environment, + r_environment=r_environment, entrypoint=entrypoint, quarto_inspection=quarto_inspection, image=image, @@ -597,10 +613,13 @@ def make_notebook_source_bundle( image: Optional[str] = None, env_management_py: Optional[bool] = None, env_management_r: Optional[bool] = None, + r_environment: Optional[REnvironment] = None, ) -> IO[bytes]: """Create a bundle containing the specified notebook and python environment. Returns a file-like object containing the bundle tarball. + + :param r_environment: optional R dependencies detected from renv.lock to add to the manifest. """ if extra_files is None: extra_files = [] @@ -615,6 +634,7 @@ def make_notebook_source_bundle( image, env_management_py, env_management_r, + r_environment, ) if hide_all_input: if "jupyter" not in manifest: @@ -660,12 +680,15 @@ def make_quarto_source_bundle( image: Optional[str] = None, env_management_py: Optional[bool] = None, env_management_r: Optional[bool] = None, + r_environment: Optional[REnvironment] = None, ) -> typing.IO[bytes]: """ Create a bundle containing the specified Quarto content and (optional) python environment. Returns a file-like object containing the bundle tarball. + + :param r_environment: optional R dependencies detected from renv.lock to add to the manifest. """ manifest, relevant_files = make_quarto_manifest( file_or_directory, @@ -677,6 +700,7 @@ def make_quarto_source_bundle( image, env_management_py, env_management_r, + r_environment, ) bundle_file = tempfile.TemporaryFile(prefix="rsc_bundle") @@ -881,6 +905,7 @@ def make_api_manifest( image: Optional[str] = None, env_management_py: Optional[bool] = None, env_management_r: Optional[bool] = None, + r_environment: Optional[REnvironment] = None, ) -> tuple[ManifestData, list[str]]: """ Makes a manifest for an API. @@ -896,6 +921,7 @@ def make_api_manifest( The server administrator is responsible for installing packages in the runtime environment. Default = None. :param env_management_r: False prevents Connect from managing the R environment for this bundle. The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param r_environment: optional R dependencies detected from renv.lock to add to the manifest. :return: the manifest and a list of the files involved. """ if is_environment_dir(directory): @@ -920,6 +946,7 @@ def make_api_manifest( image, env_management_py, env_management_r, + r_environment, ) manifest_add_buffer(manifest, environment.filename, environment.contents) @@ -1268,6 +1295,7 @@ def make_voila_bundle( image: Optional[str] = None, env_management_py: Optional[bool] = None, env_management_r: Optional[bool] = None, + r_environment: Optional[REnvironment] = None, multi_notebook: bool = False, ) -> typing.IO[bytes]: """ @@ -1287,6 +1315,7 @@ def make_voila_bundle( The server administrator is responsible for installing packages in the runtime environment. Default = None. :param env_management_r: False prevents Connect from managing the R environment for this bundle. The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param r_environment: optional R dependencies detected from renv.lock to add to the manifest. :return: a file-like object containing the bundle tarball. """ @@ -1300,6 +1329,7 @@ def make_voila_bundle( image=image, env_management_py=env_management_py, env_management_r=env_management_r, + r_environment=r_environment, multi_notebook=multi_notebook, ) @@ -1334,6 +1364,7 @@ def make_api_bundle( image: Optional[str] = None, env_management_py: Optional[bool] = None, env_management_r: Optional[bool] = None, + r_environment: Optional[REnvironment] = None, ) -> typing.IO[bytes]: """ Create an API bundle, given a directory path and a manifest. @@ -1349,6 +1380,7 @@ def make_api_bundle( The server administrator is responsible for installing packages in the runtime environment. Default = None. :param env_management_r: False prevents Connect from managing the R environment for this bundle. The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param r_environment: optional R dependencies detected from renv.lock to add to the manifest. :return: a file-like object containing the bundle tarball. """ manifest, relevant_files = make_api_manifest( @@ -1361,6 +1393,7 @@ def make_api_bundle( image, env_management_py, env_management_r, + r_environment, ) bundle_file = tempfile.TemporaryFile(prefix="rsc_bundle") @@ -1524,6 +1557,7 @@ def make_quarto_manifest( image: Optional[str] = None, env_management_py: Optional[bool] = None, env_management_r: Optional[bool] = None, + r_environment: Optional[REnvironment] = None, ) -> tuple[ManifestData, list[str]]: """ Makes a manifest for a Quarto project. @@ -1539,6 +1573,7 @@ def make_quarto_manifest( The server administrator is responsible for installing packages in the runtime environment. Default = None. :param env_management_r: False prevents Connect from managing the R environment for this bundle. The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param r_environment: optional R dependencies detected from renv.lock to add to the manifest. :return: the manifest and a list of the files involved. """ if environment: @@ -1587,6 +1622,7 @@ def make_quarto_manifest( image, env_management_py, env_management_r, + r_environment, ) if environment: @@ -1848,12 +1884,14 @@ def write_notebook_manifest_json( image: Optional[str] = None, env_management_py: Optional[bool] = None, env_management_r: Optional[bool] = None, + r_environment: Optional[REnvironment] = None, ) -> bool: """ Creates and writes a manifest.json file for the given entry point file. If the application mode is not provided, an attempt will be made to resolve one based on the extension portion of the entry point file. + :param r_environment: optional R dependencies detected from renv.lock to add to the manifest. :param entry_point_file: the entry point file (Jupyter notebook, etc.) to build the manifest for. :param environment: the Python environment to start with. This should be what's @@ -1891,6 +1929,7 @@ def write_notebook_manifest_json( image, env_management_py, env_management_r, + r_environment, ) if hide_all_input or hide_tagged_input: if "jupyter" not in manifest_data: @@ -1930,6 +1969,7 @@ def create_voila_manifest( image: Optional[str] = None, env_management_py: Optional[bool] = None, env_management_r: Optional[bool] = None, + r_environment: Optional[REnvironment] = None, multi_notebook: bool = False, ) -> Manifest: """ @@ -1949,6 +1989,7 @@ def create_voila_manifest( The server administrator is responsible for installing packages in the runtime environment. Default = None. :param env_management_r: False prevents Connect from managing the R environment for this bundle. The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param r_environment: optional R dependencies detected from renv.lock to add to the manifest. :return: the manifest data structure. """ if not path: @@ -1989,6 +2030,7 @@ def create_voila_manifest( manifest = Manifest( app_mode=AppModes.JUPYTER_VOILA, environment=environment, + r_environment=r_environment, entrypoint=entrypoint, image=image, env_management_py=env_management_py, @@ -2017,6 +2059,7 @@ def write_voila_manifest_json( image: Optional[str] = None, env_management_py: Optional[bool] = None, env_management_r: Optional[bool] = None, + r_environment: Optional[REnvironment] = None, multi_notebook: bool = False, ) -> bool: """ @@ -2036,6 +2079,7 @@ def write_voila_manifest_json( The server administrator is responsible for installing packages in the runtime environment. Default = None. :param env_management_r: False prevents Connect from managing the R environment for this bundle. The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param r_environment: optional R dependencies detected from renv.lock to add to the manifest. :return: whether the manifest was written. """ manifest = create_voila_manifest( @@ -2048,6 +2092,7 @@ def write_voila_manifest_json( image=image, env_management_py=env_management_py, env_management_r=env_management_r, + r_environment=r_environment, multi_notebook=multi_notebook, ) @@ -2123,6 +2168,7 @@ def write_api_manifest_json( image: Optional[str] = None, env_management_py: Optional[bool] = None, env_management_r: Optional[bool] = None, + r_environment: Optional[REnvironment] = None, ) -> bool: """ Creates and writes a manifest.json file for the given entry point file. If @@ -2141,6 +2187,7 @@ def write_api_manifest_json( The server administrator is responsible for installing packages in the runtime environment. Default = None. :param env_management_r: False prevents Connect from managing the R environment for this bundle. The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param r_environment: optional R dependencies detected from renv.lock to add to the manifest. :return: whether or not the environment file (requirements.txt, environment.yml, etc.) that goes along with the manifest exists. """ @@ -2155,6 +2202,7 @@ def write_api_manifest_json( image, env_management_py, env_management_r, + r_environment, ) manifest_path = join(directory, "manifest.json") @@ -2249,6 +2297,7 @@ def write_quarto_manifest_json( image: Optional[str] = None, env_management_py: Optional[bool] = None, env_management_r: Optional[bool] = None, + r_environment: Optional[REnvironment] = None, ) -> None: """ Creates and writes a manifest.json file for the given Quarto project. @@ -2264,6 +2313,7 @@ def write_quarto_manifest_json( The server administrator is responsible for installing packages in the runtime environment. Default = None. :param env_management_r: False prevents Connect from managing the R environment for this bundle. The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param r_environment: optional R dependencies detected from renv.lock to add to the manifest. """ manifest, _ = make_quarto_manifest( @@ -2276,6 +2326,7 @@ def write_quarto_manifest_json( image, env_management_py, env_management_r, + r_environment, ) base_dir = file_or_directory diff --git a/rsconnect/environment_r.py b/rsconnect/environment_r.py new file mode 100644 index 00000000..1d6142b3 --- /dev/null +++ b/rsconnect/environment_r.py @@ -0,0 +1,299 @@ +"""Detects R dependencies from a project's renv.lock file. + +Given a directory that contains an renv.lock lockfile, this module parses it +into the R version and package metadata needed for the deployment manifest. +The parse is pure: it reads only renv.lock and never invokes R or inspects +locally installed R packages. This mirrors how Posit Publisher resolves R +dependencies for Python content that also uses R (e.g. rpy2 apps). +""" + +from __future__ import annotations + +import json +import os +from typing import Any, Optional, Sequence, cast + +from .exception import RSConnectException +from .log import logger + +DEFAULT_R_PACKAGE_FILE = "renv.lock" + +# Repositories renv always assumes are available, keyed by the names renv writes +# into each package's "Repository" field. +_DEFAULT_REPOSITORIES = { + "CRAN": "https://cloud.r-project.org", + "RSPM": "https://packagemanager.posit.co/cran/latest", +} + + +class REnvironment: + """R dependencies resolved from a project's renv.lock file. + + Captures the R version and the package metadata Connect needs to restore the + R library when deploying Python content that also depends on R. + """ + + def __init__( + self, + r_version: str, + packages: dict[str, dict[str, Any]], + ): + self.r_version = r_version + self.packages = packages + self.lockfile = DEFAULT_R_PACKAGE_FILE + + @classmethod + def create(cls, directory: str) -> Optional["REnvironment"]: + """Resolve R dependencies from an renv.lock file in a project directory. + + Returns None when there is no renv.lock to resolve, so callers can treat + R detection as opt-in based on the presence of the lockfile. The location + honors RENV_PATHS_LOCKFILE and otherwise defaults to /renv.lock. + + :param directory: path to the project directory that may contain renv.lock. + """ + lockfile_path = _renv_lockfile_path(directory) + if not os.path.exists(lockfile_path): + return None + + with open(lockfile_path, encoding="utf-8") as f: + try: + parsed = json.load(f) + except json.JSONDecodeError as err: + raise RSConnectException(f"{lockfile_path} is not valid JSON: {err}") from err + + # A compatible lockfile is a JSON object whose "R" section lists the + # Repositories needed to resolve every package back to a URL. renv < 1.1.0 + # omits Repositories; malformed lockfiles may be a non-object or carry + # null/non-dict sections. Treat all of these as incompatible rather than + # letting them surface as an AttributeError. + incompatible_msg = ( + f"{DEFAULT_R_PACKAGE_FILE} is not compatible: missing Repositories section. " + "Regenerate the lockfile with renv >= 1.1.0." + ) + if not isinstance(parsed, dict): + raise RSConnectException(incompatible_msg) + lockfile = cast("dict[str, Any]", parsed) + + r_section = lockfile.get("R") + if not isinstance(r_section, dict): + raise RSConnectException(incompatible_msg) + r_section = cast("dict[str, Any]", r_section) + if not r_section.get("Repositories"): + raise RSConnectException(incompatible_msg) + if "Bioconductor" in lockfile and not isinstance(lockfile["Bioconductor"], dict): + raise RSConnectException(incompatible_msg) + + logger.debug(f"Resolving R dependencies from {lockfile_path}") + return cls( + r_version=r_section.get("Version", ""), + packages=_lockfile_to_manifest_packages(lockfile), + ) + + +def _renv_lockfile_path(directory: str) -> str: + # Mimics renv's renv_paths_lockfile() in R/paths.R: RENV_PATHS_LOCKFILE + # overrides the location, except a trailing slash means "a directory" so + # renv.lock is appended. An absolute override is used verbatim; a relative + # override resolves against the project directory (matching renv). + # With no override we fall back to renv's default of /renv.lock. + override = os.environ.get("RENV_PATHS_LOCKFILE") + if override: + if override.endswith(("/", "\\")): + override += DEFAULT_R_PACKAGE_FILE + if not os.path.isabs(override): + return os.path.join(directory, override) + return override + return os.path.join(directory, DEFAULT_R_PACKAGE_FILE) + + +def _lockfile_to_manifest_packages(lockfile: Any) -> dict[str, dict[str, Any]]: + repo_name_to_url = _find_all_repositories(lockfile) + result: dict[str, dict[str, Any]] = {} + for pkg_name, pkg in lockfile.get("Packages", {}).items(): + source, repository = _resolve_package_source(pkg, repo_name_to_url) + if not source: + raise RSConnectException( + f"Package {pkg_name} has an unresolved source; cannot generate manifest entry. " + "Use --exclude-renv to deploy without R dependency detection." + ) + if not repository: + raise RSConnectException( + f"Package {pkg_name} has an unresolved repository; cannot generate manifest entry. " + "Use --exclude-renv to deploy without R dependency detection." + ) + description = _build_description( + pkg, + repository, + { + "Package": pkg_name, + "Version": pkg.get("Version", ""), + "Type": "Package", + "Title": pkg.get("Title") or f"{source} R package", + }, + ) + result[pkg_name] = {"Source": source, "Repository": repository, "description": description} + return result + + +def _find_all_repositories(lockfile: Any) -> dict[str, str]: + repos = dict(_DEFAULT_REPOSITORIES) + + bioc_version = lockfile.get("Bioconductor", {}).get("Version") + if bioc_version: + base = f"https://bioconductor.org/packages/{bioc_version}" + repos["BioCsoft"] = f"{base}/bioc" + repos["BioCann"] = f"{base}/data/annotation" + repos["BioCexp"] = f"{base}/data/experiment" + repos["BioCworkflows"] = f"{base}/workflows" + repos["BioCbooks"] = f"{base}/books" + + for repo in lockfile.get("R", {}).get("Repositories", []): + repos[repo["Name"]] = repo["URL"].rstrip("/") + + # Packages installed from a remote repository (e.g. a private RSPM) carry the + # repository URL on the package itself; register it under its short name. + for pkg in lockfile.get("Packages", {}).values(): + remote_repos = pkg.get("RemoteRepos") + repository = pkg.get("Repository") + if remote_repos and repository and _is_url(remote_repos): + repos[repository] = remote_repos.rstrip("/") + + return repos + + +def _resolve_package_source(pkg: Any, repo_name_to_url: dict[str, str]) -> tuple[str, str]: + repo_identifier = pkg.get("RemoteRepos") or pkg.get("Repository") or "" + pkg_ref = _remote_pkg_ref_or_derived(pkg) + remote_type = pkg.get("RemoteType") + + if not repo_identifier and remote_type: + # git-hosted package with no standard repository + return remote_type, (_remote_repo_url(remote_type, pkg_ref) or pkg.get("RemoteUrl") or "") + + if repo_identifier or pkg.get("Source") == "Bioconductor": + return _resolve_repo_and_source(repo_name_to_url, repo_identifier, pkg.get("Source")) + + # No resolution possible here; the caller validates the source/repository are non-empty. + return pkg.get("Source") or "", pkg.get("Repository") or "" + + +def _resolve_repo_and_source(repo_name_to_url: dict[str, str], repo_str: str, src: Optional[str]) -> tuple[str, str]: + if _is_url(repo_str): + repo_url = repo_str.rstrip("/") + repo_name = repo_url + for name, url in repo_name_to_url.items(): + if url == repo_url: + repo_name = name + break + elif repo_str: + url = repo_name_to_url.get(repo_str) + if url is None: + raise RSConnectException(f"repository {repo_str} cannot be resolved to a URL") + repo_url = url + repo_name = repo_str + else: + # Caller guarantees src == "Bioconductor" once repo_str is empty. + bioc_url = repo_name_to_url.get("BioCsoft") + if bioc_url is None: + raise RSConnectException( + "Bioconductor package source specified but no Bioconductor repositories are available" + ) + repo_url = bioc_url + repo_name = "BioCsoft" + + is_bioc = src == "Bioconductor" or repo_name.startswith("BioC") or "bioconductor.org/packages/" in repo_url.lower() + source = "Bioconductor" if is_bioc else repo_name + return source, repo_url + + +def _build_description(pkg: Any, resolved_repo: str, initial: dict[str, Any]) -> dict[str, Any]: + # The manifest "description" mirrors the package DESCRIPTION. Connect treats it + # as a plain JSON object, so key order is just deterministic insertion order, + # not a contract. Writes are first-write-wins: setIf only fills a key the first + # time a truthy value is seen, so derived values never overwrite explicit ones. + desc = dict(initial) + + def set_if(key: str, value: Any) -> None: + # setdefault keeps first-write-wins; the truthy guard avoids writing null/empty fields. + if value: + desc.setdefault(key, value) + + for key in ( + "Hash", + "Authors@R", + "Description", + "License", + "Maintainer", + "VignetteBuilder", + "RoxygenNote", + "Encoding", + "NeedsCompilation", + "Author", + "SystemRequirements", + "RemoteType", + "RemoteRef", + "RemoteRepos", + "RemoteReposName", + "RemotePkgPlatform", + "RemoteSha", + "RemoteHost", + "RemoteRepo", + "RemoteUsername", + "RemoteSubdir", + ): + set_if(key, pkg.get(key)) + set_if("GithubSubdir", pkg.get("RemoteSubdir")) + set_if("RemoteUrl", pkg.get("RemoteUrl")) + + pkg_ref = _remote_pkg_ref_or_derived(pkg) + if pkg_ref: + desc["RemotePkgRef"] = pkg_ref + + if pkg.get("RemoteType") == "github" and pkg.get("RemotePkgRef"): + set_if("URL", f"https://github.com/{pkg['RemotePkgRef']}") + set_if("BugReports", f"https://github.com/{pkg['RemotePkgRef']}/issues") + + set_if("URL", pkg.get("URL")) + set_if("BugReports", pkg.get("BugReports")) + set_if("Repository", resolved_repo) + set_if("Config/testthat/edition", pkg.get("Config/testthat/edition")) + set_if("Config/Needs/website", pkg.get("Config/Needs/website")) + set_if("Imports", _join_list(pkg.get("Imports"))) + set_if("Suggests", _join_list(pkg.get("Suggests"))) + set_if("LinkingTo", _join_list(pkg.get("LinkingTo"))) + + if pkg.get("Depends"): + set_if("Depends", _join_list(pkg.get("Depends"))) + elif pkg.get("Requirements"): + set_if("Depends", _join_list(pkg.get("Requirements"))) + + return desc + + +def _remote_pkg_ref_or_derived(pkg: Any) -> str: + if pkg.get("RemotePkgRef"): + return pkg["RemotePkgRef"] + if pkg.get("RemoteUsername") and pkg.get("RemoteRepo"): + return f"{pkg['RemoteUsername']}/{pkg['RemoteRepo']}" + return "" + + +def _remote_repo_url(remote_type: str, pkg_ref: str) -> str: + if not pkg_ref: + return "" + hosts = { + "github": "https://github.com/", + "gitlab": "https://gitlab.com/", + "bitbucket": "https://bitbucket.org/", + } + host = hosts.get(remote_type) + return f"{host}{pkg_ref}" if host else "" + + +def _join_list(value: Optional[Sequence[str]]) -> Optional[str]: + return ", ".join(value) if value else None + + +def _is_url(value: str) -> bool: + return value.startswith(("http://", "https://", "ftp://")) diff --git a/rsconnect/main.py b/rsconnect/main.py index 3df3fb98..e4679d40 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -114,6 +114,7 @@ write_voila_manifest_json, ) from .environment_node import NodeEnvironment +from .environment_r import REnvironment from .environment import Environment, fake_module_file_from_directory from .exception import RSConnectException from .git_metadata import detect_git_metadata @@ -470,6 +471,14 @@ def runtime_environment_args(func: Callable[P, T]) -> Callable[P, T]: "required packages in the correct R environment on the Connect server.", callback=env_management_callback, ) + @click.option( + "--exclude-renv", + "exclude_renv", + is_flag=True, + default=False, + help="Skip renv.lock detection. R dependencies will not be added to the manifest, " + "even when an renv.lock file is present (in the content directory or at RENV_PATHS_LOCKFILE).", + ) @functools.wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs): return func(*args, **kwargs) @@ -1449,6 +1458,7 @@ def deploy_notebook( disable_env_management: Optional[bool], env_management_py: Optional[bool], env_management_r: Optional[bool], + exclude_renv: bool, draft: bool, no_verify: bool = False, package_installer: Optional[PackageInstaller] = None, @@ -1473,6 +1483,7 @@ def deploy_notebook( override_python_version=override_python_version, package_manager=package_installer, ) + r_environment = None if exclude_renv else REnvironment.create(base_dir) ce = RSConnectExecutor( ctx=ctx, @@ -1517,6 +1528,7 @@ def deploy_notebook( image=image, env_management_py=env_management_py, env_management_r=env_management_r, + r_environment=r_environment, ) ce.deploy_bundle(activate=not draft).save_deployed_info().emit_task_log() if not no_verify: @@ -1614,6 +1626,7 @@ def deploy_voila( disable_env_management: Optional[bool], env_management_py: Optional[bool], env_management_r: Optional[bool], + exclude_renv: bool, title: Optional[str], env_vars: dict[str, str], verbose: int, @@ -1645,6 +1658,7 @@ def deploy_voila( override_python_version=override_python_version, package_manager=package_installer, ) + r_environment = None if exclude_renv else REnvironment.create(base_dir) ce = RSConnectExecutor( ctx=ctx, @@ -1682,6 +1696,7 @@ def deploy_voila( image=image, env_management_py=env_management_py, env_management_r=env_management_r, + r_environment=r_environment, multi_notebook=multi_notebook, ).deploy_bundle(activate=not draft).save_deployed_info().emit_task_log() if not no_verify: @@ -1807,6 +1822,14 @@ def deploy_manifest( ) @click.argument("directory", type=click.Path(exists=True, dir_okay=True, file_okay=False)) @shinyapps_deploy_args +@click.option( + "--exclude-renv", + "exclude_renv", + is_flag=True, + default=False, + help="Skip renv.lock detection. R dependencies will not be added to the manifest, " + "even when an renv.lock file is present (in the content directory or at RENV_PATHS_LOCKFILE).", +) @cli_exception_handler @click.pass_context def deploy_pyproject( @@ -1830,6 +1853,7 @@ def deploy_pyproject( visibility: Optional[str], no_verify: bool, draft: bool, + exclude_renv: bool, metadata: tuple[str, ...] = tuple(), no_metadata: bool = False, ): @@ -1864,6 +1888,10 @@ def quickstart_hint() -> str: bundle_kwargs: dict[str, Any] = {} path = directory + # renv.lock detection mirrors the dedicated deploy commands; --exclude-renv + # opts out, otherwise detection is driven by the lockfile's presence. + r_environment = None if exclude_renv else REnvironment.create(directory) + if app_mode in (AppModes.STREAMLIT_APP, AppModes.PYTHON_SHINY, AppModes.PYTHON_FASTAPI, AppModes.PYTHON_API): if app_mode == AppModes.PYTHON_SHINY: entrypoint = resolve_shiny_express_entrypoint(entrypoint, directory) @@ -1874,7 +1902,12 @@ def quickstart_hint() -> str: ) bundle_builder = make_api_bundle bundle_args = (directory, entrypoint, app_mode, environment, extra_files, excludes) - bundle_kwargs = {"image": None, "env_management_py": None, "env_management_r": None} + bundle_kwargs = { + "image": None, + "env_management_py": None, + "env_management_r": None, + "r_environment": r_environment, + } elif app_mode == AppModes.JUPYTER_NOTEBOOK: # This is "jupyter-static" path = str(Path(directory) / entrypoint) environment = Environment.create_python_environment( @@ -1889,6 +1922,7 @@ def quickstart_hint() -> str: "image": None, "env_management_py": None, "env_management_r": None, + "r_environment": r_environment, } elif app_mode == AppModes.JUPYTER_VOILA: environment = Environment.create_python_environment( @@ -1902,6 +1936,7 @@ def quickstart_hint() -> str: "image": None, "env_management_py": None, "env_management_r": None, + "r_environment": r_environment, "multi_notebook": False, } elif app_mode in (AppModes.STATIC_QUARTO, AppModes.SHINY_QUARTO): @@ -1922,7 +1957,12 @@ def quickstart_hint() -> str: ) bundle_builder = create_quarto_deployment_bundle bundle_args = (path, extra_files, excludes, app_mode, inspect, environment) - bundle_kwargs = {"image": None, "env_management_py": None, "env_management_r": None} + bundle_kwargs = { + "image": None, + "env_management_py": None, + "env_management_r": None, + "r_environment": r_environment, + } else: raise RSConnectException(f"Unsupported app_mode '{target.configured_app_mode}' in [tool.rsconnect]") @@ -2065,6 +2105,7 @@ def deploy_quarto( disable_env_management: bool, env_management_py: bool, env_management_r: bool, + exclude_renv: bool, no_verify: bool, draft: bool, package_installer: Optional[PackageInstaller], @@ -2097,6 +2138,10 @@ def deploy_quarto( override_python_version=override_python_version, ) + # R/Quarto content can use renv regardless of the Quarto engine, so detect it + # whenever a lockfile is present unless the user opted out. + r_environment = None if exclude_renv else REnvironment.create(base_dir) + ce = RSConnectExecutor( ctx=ctx, name=name, @@ -2135,6 +2180,7 @@ def deploy_quarto( image=image, env_management_py=env_management_py, env_management_r=env_management_r, + r_environment=r_environment, ) .deploy_bundle(activate=not draft) .save_deployed_info() @@ -2510,6 +2556,7 @@ def deploy_app( disable_env_management: Optional[bool], env_management_py: Optional[bool], env_management_r: Optional[bool], + exclude_renv: bool, account: Optional[str], token: Optional[str], secret: Optional[str], @@ -2530,6 +2577,7 @@ def deploy_app( override_python_version=override_python_version, package_manager=package_installer, ) + r_environment = None if exclude_renv else REnvironment.create(directory) if app_mode == AppModes.PYTHON_SHINY: entrypoint = resolve_shiny_express_entrypoint(entrypoint, directory) @@ -2593,6 +2641,7 @@ def deploy_app( image=image, env_management_py=env_management_py, env_management_r=env_management_r, + r_environment=r_environment, ) ce.deploy_bundle(activate=not draft) ce.save_deployed_info() @@ -2863,6 +2912,7 @@ def write_manifest_notebook( disable_env_management: Optional[bool], env_management_py: Optional[bool], env_management_r: Optional[bool], + exclude_renv: bool, hide_all_input: Optional[bool] = None, hide_tagged_input: Optional[bool] = None, package_installer: Optional[PackageInstaller] = None, @@ -2889,6 +2939,7 @@ def write_manifest_notebook( app_file=file, package_manager=package_installer, ) + r_environment = None if exclude_renv else REnvironment.create(base_dir) generate_env = requirements_file is None with cli_feedback("Creating manifest.json"): @@ -2902,6 +2953,7 @@ def write_manifest_notebook( image, env_management_py, env_management_r, + r_environment, ) if environment_file_exists and not generate_env: @@ -2999,6 +3051,7 @@ def write_manifest_voila( disable_env_management: Optional[bool], env_management_py: Optional[bool], env_management_r: Optional[bool], + exclude_renv: bool, multi_notebook: bool, package_installer: Optional[PackageInstaller] = None, requirements_file: Optional[str] = None, @@ -3022,6 +3075,7 @@ def write_manifest_voila( app_file=path, package_manager=package_installer, ) + r_environment = None if exclude_renv else REnvironment.create(base_dir) environment_file_exists = exists(join(base_dir, environment.filename)) generate_env = requirements_file is None @@ -3045,6 +3099,7 @@ def write_manifest_voila( image, env_management_py, env_management_r, + r_environment, multi_notebook, ) @@ -3137,6 +3192,7 @@ def write_manifest_quarto( disable_env_management: Optional[bool], env_management_py: Optional[bool], env_management_r: Optional[bool], + exclude_renv: bool, package_installer: Optional[PackageInstaller], requirements_file: Optional[str], ): @@ -3163,6 +3219,10 @@ def write_manifest_quarto( "--requirements-file is only supported for Quarto content using the Jupyter engine." ) + # R/Quarto content can use renv regardless of the Quarto engine, so detect it + # whenever a lockfile is present unless the user opted out. + r_environment = None if exclude_renv else REnvironment.create(base_dir) + environment = None generate_env = False if "jupyter" in engines: @@ -3198,6 +3258,7 @@ def write_manifest_quarto( image, env_management_py, env_management_r, + r_environment, ) @@ -3228,6 +3289,14 @@ def write_manifest_quarto( ), ) @click.option("--verbose", "-v", "verbose", is_flag=True, help="Print detailed messages") +@click.option( + "--exclude-renv", + "exclude_renv", + is_flag=True, + default=False, + help="Skip renv.lock detection. R dependencies will not be added to the manifest, " + "even when an renv.lock file is present (in the content directory or at RENV_PATHS_LOCKFILE).", +) @click.argument("directory", type=click.Path(exists=True, dir_okay=True, file_okay=False)) @cli_exception_handler @click.pass_context @@ -3236,6 +3305,7 @@ def write_manifest_pyproject( overwrite: bool, requirements_file: Optional[str], verbose: int, + exclude_renv: bool, directory: str, ): set_verbosity(verbose) @@ -3261,6 +3331,10 @@ def quickstart_hint() -> str: extra_files: tuple[str, ...] = tuple() excludes: tuple[str, ...] = tuple() + # renv.lock detection mirrors the dedicated write-manifest commands; + # --exclude-renv opts out, otherwise detection is driven by the lockfile. + r_environment = None if exclude_renv else REnvironment.create(directory) + api_modes = (AppModes.STREAMLIT_APP, AppModes.PYTHON_SHINY, AppModes.PYTHON_FASTAPI, AppModes.PYTHON_API) entrypoint_manifest_modes = ( AppModes.JUPYTER_NOTEBOOK, @@ -3309,6 +3383,7 @@ def inspect_python_environment() -> Environment: image=None, env_management_py=None, env_management_r=None, + r_environment=r_environment, ) elif app_mode == AppModes.JUPYTER_NOTEBOOK: # This is "jupyter-static" environment = inspect_python_environment() @@ -3323,6 +3398,7 @@ def inspect_python_environment() -> Environment: image=None, env_management_py=None, env_management_r=None, + r_environment=r_environment, ) elif app_mode == AppModes.JUPYTER_VOILA: environment = inspect_python_environment() @@ -3337,6 +3413,7 @@ def inspect_python_environment() -> Environment: image=None, env_management_py=None, env_management_r=None, + r_environment=r_environment, multi_notebook=False, ) elif app_mode in (AppModes.STATIC_QUARTO, AppModes.SHINY_QUARTO): @@ -3362,6 +3439,7 @@ def inspect_python_environment() -> Environment: image=None, env_management_py=None, env_management_r=None, + r_environment=r_environment, ) # The manifest references environment.filename (e.g. a requirements.txt @@ -3539,6 +3617,7 @@ def manifest_writer( disable_env_management: Optional[bool], env_management_py: Optional[bool], env_management_r: Optional[bool], + exclude_renv: bool, package_installer: Optional[PackageInstaller], requirements_file: Optional[str], ): @@ -3557,6 +3636,7 @@ def manifest_writer( image, env_management_py, env_management_r, + exclude_renv, package_installer=package_installer, requirements_file=resolved_requirements_file, ) @@ -3680,6 +3760,7 @@ def _write_framework_manifest( image: Optional[str], env_management_py: Optional[bool], env_management_r: Optional[bool], + exclude_renv: bool = False, package_installer: Optional[PackageInstaller] = None, requirements_file: Optional[str] = None, ): @@ -3720,6 +3801,7 @@ def _write_framework_manifest( override_python_version=override_python_version, python=python, ) + r_environment = None if exclude_renv else REnvironment.create(directory) if app_mode == AppModes.PYTHON_SHINY: with cli_feedback("Inspecting Shiny for Python app"): @@ -3736,6 +3818,7 @@ def _write_framework_manifest( image, env_management_py, env_management_r, + r_environment, ) generate_env = resolved_requirements_file is None diff --git a/tests/test_bundle.py b/tests/test_bundle.py index ebe71838..591cbbda 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -280,6 +280,58 @@ def test_make_api_bundle_package_manager_pip(self): assert manifest["python"]["package_manager"]["name"] == "pip" assert manifest["python"]["package_manager"].get("allow_uv") is False + def test_make_api_bundle_injects_renv_packages(self): + from .utils import get_api_path + from rsconnect.environment_r import REnvironment + + directory = get_api_path("stock-api-fastapi", "") + environment = Environment.create_python_environment(directory) + entrypoint = "app:app" + + lock_dir = tempfile.mkdtemp() + with open(join(lock_dir, "renv.lock"), "w") as fp: + json.dump( + { + "R": { + "Version": "4.3.1", + "Repositories": [{"Name": "CRAN", "URL": "https://cloud.r-project.org"}], + }, + "Packages": { + "R6": {"Package": "R6", "Version": "2.5.1", "Source": "Repository", "Repository": "CRAN"}, + }, + }, + fp, + ) + r_environment = REnvironment.create(lock_dir) + + with make_api_bundle( + directory, + entrypoint, + AppModes.PYTHON_FASTAPI, + environment, + extra_files=[], + excludes=[], + r_environment=r_environment, + ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: + manifest = json.loads(tar.extractfile("manifest.json").read().decode("utf-8")) + assert manifest["platform"] == "4.3.1" + assert manifest["packages"]["R6"]["Source"] == "CRAN" + assert manifest["packages"]["R6"]["Repository"] == "https://cloud.r-project.org" + + # Without an R environment (e.g. no renv.lock or --exclude-renv), the + # manifest is unchanged: no R sections are emitted. + with make_api_bundle( + directory, + entrypoint, + AppModes.PYTHON_FASTAPI, + environment, + extra_files=[], + excludes=[], + ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: + manifest = json.loads(tar.extractfile("manifest.json").read().decode("utf-8")) + assert "platform" not in manifest + assert "packages" not in manifest + def test_default_package_manager_omits_allow_uv(self): directory = get_dir("pip1") nb_path = join(directory, "dummy.ipynb") diff --git a/tests/test_environment_r.py b/tests/test_environment_r.py new file mode 100644 index 00000000..b84c3f6b --- /dev/null +++ b/tests/test_environment_r.py @@ -0,0 +1,249 @@ +import json +from os.path import join + +import pytest + +from rsconnect.environment_r import REnvironment +from rsconnect.exception import RSConnectException + + +def write_lockfile(directory, lockfile): + with open(join(str(directory), "renv.lock"), "w", encoding="utf-8") as f: + json.dump(lockfile, f) + + +def test_create_returns_none_without_lockfile(tmp_path): + assert REnvironment.create(str(tmp_path)) is None + + +def test_resolves_cran_and_rspm_packages(tmp_path): + write_lockfile( + tmp_path, + { + "R": { + "Version": "4.3.1", + "Repositories": [ + {"Name": "CRAN", "URL": "https://cloud.r-project.org"}, + {"Name": "RSPM", "URL": "https://packagemanager.posit.co/cran/latest/"}, + ], + }, + "Packages": { + "R6": { + "Package": "R6", + "Version": "2.5.1", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "470851b6d5d0ac559e9d01bb352b4021", + "Requirements": ["R"], + }, + "glue": { + "Package": "glue", + "Version": "1.6.2", + "Source": "Repository", + "Repository": "RSPM", + "Title": "Interpreted String Literals", + }, + }, + }, + ) + + env = REnvironment.create(str(tmp_path)) + assert env is not None + assert env.r_version == "4.3.1" + assert env.lockfile == "renv.lock" + + r6 = env.packages["R6"] + assert r6["Source"] == "CRAN" + assert r6["Repository"] == "https://cloud.r-project.org" + # Connect treats description as a JSON object, so assert key presence and + # values rather than a specific key order. + description = r6["description"] + assert {"Package", "Version", "Type", "Title", "Hash", "Repository", "Depends"} <= description.keys() + assert description["Package"] == "R6" + assert description["Version"] == "2.5.1" + assert description["Type"] == "Package" + assert description["Hash"] == "470851b6d5d0ac559e9d01bb352b4021" + assert description["Repository"] == "https://cloud.r-project.org" + assert description["Title"] == "CRAN R package" + assert description["Depends"] == "R" + + glue = env.packages["glue"] + assert glue["Source"] == "RSPM" + # Trailing slash on the configured repository URL is stripped. + assert glue["Repository"] == "https://packagemanager.posit.co/cran/latest" + assert glue["description"]["Title"] == "Interpreted String Literals" + + +def test_resolves_bioconductor_package(tmp_path): + write_lockfile( + tmp_path, + { + "R": {"Version": "4.3.1", "Repositories": [{"Name": "CRAN", "URL": "https://cloud.r-project.org"}]}, + "Bioconductor": {"Version": "3.18"}, + "Packages": { + "Biobase": {"Package": "Biobase", "Version": "2.62.0", "Source": "Bioconductor"}, + }, + }, + ) + + env = REnvironment.create(str(tmp_path)) + assert env is not None + biobase = env.packages["Biobase"] + assert biobase["Source"] == "Bioconductor" + assert biobase["Repository"] == "https://bioconductor.org/packages/3.18/bioc" + + +def test_resolves_github_remote_package(tmp_path): + write_lockfile( + tmp_path, + { + "R": {"Version": "4.3.1", "Repositories": [{"Name": "CRAN", "URL": "https://cloud.r-project.org"}]}, + "Packages": { + "shiny": { + "Package": "shiny", + "Version": "1.8.0", + "Source": "GitHub", + "RemoteType": "github", + "RemoteUsername": "rstudio", + "RemoteRepo": "shiny", + "RemotePkgRef": "rstudio/shiny", + "RemoteSha": "abc123", + }, + }, + }, + ) + + env = REnvironment.create(str(tmp_path)) + assert env is not None + shiny = env.packages["shiny"] + assert shiny["Source"] == "github" + assert shiny["Repository"] == "https://github.com/rstudio/shiny" + description = shiny["description"] + assert description["RemoteType"] == "github" + assert description["RemotePkgRef"] == "rstudio/shiny" + assert description["URL"] == "https://github.com/rstudio/shiny" + assert description["BugReports"] == "https://github.com/rstudio/shiny/issues" + + +def test_incompatible_lockfile_raises(tmp_path): + write_lockfile( + tmp_path, + { + "R": {"Version": "4.3.1"}, + "Packages": {"R6": {"Package": "R6", "Version": "2.5.1", "Source": "Repository", "Repository": "CRAN"}}, + }, + ) + + with pytest.raises(RSConnectException, match="renv >= 1.1.0"): + REnvironment.create(str(tmp_path)) + + +def test_resolves_private_repo_from_remote_repos(tmp_path): + write_lockfile( + tmp_path, + { + "R": {"Version": "4.3.1", "Repositories": [{"Name": "CRAN", "URL": "https://cloud.r-project.org"}]}, + "Packages": { + "mystery": { + "Package": "mystery", + "Version": "1.0.0", + "Source": "Repository", + "Repository": "PRIVATE", + "RemoteRepos": "https://my.rspm/cran/latest", + }, + }, + }, + ) + + env = REnvironment.create(str(tmp_path)) + assert env is not None + mystery = env.packages["mystery"] + # The short name resolves because the package carries a RemoteRepos URL. + assert mystery["Source"] == "PRIVATE" + assert mystery["Repository"] == "https://my.rspm/cran/latest" + + +def test_unresolvable_repository_raises(tmp_path): + write_lockfile( + tmp_path, + { + "R": {"Version": "4.3.1", "Repositories": [{"Name": "CRAN", "URL": "https://cloud.r-project.org"}]}, + "Packages": { + "mystery": { + "Package": "mystery", + "Version": "1.0.0", + "Source": "Repository", + "Repository": "PRIVATE", + }, + }, + }, + ) + + with pytest.raises(RSConnectException, match="PRIVATE cannot be resolved"): + REnvironment.create(str(tmp_path)) + + +def test_malformed_lockfile_raises(tmp_path): + # A valid-JSON but non-object lockfile must fail cleanly, not with an AttributeError. + with open(join(str(tmp_path), "renv.lock"), "w", encoding="utf-8") as f: + f.write("[]") + with pytest.raises(RSConnectException, match="not compatible"): + REnvironment.create(str(tmp_path)) + + +def test_null_r_section_raises(tmp_path): + write_lockfile(tmp_path, {"R": None, "Packages": {}}) + with pytest.raises(RSConnectException, match="renv >= 1.1.0"): + REnvironment.create(str(tmp_path)) + + +MINIMAL_LOCKFILE = { + "R": {"Version": "4.3.1", "Repositories": [{"Name": "CRAN", "URL": "https://cloud.r-project.org"}]}, + "Packages": {}, +} + + +def test_renv_paths_lockfile_absolute_override(tmp_path, monkeypatch): + # RENV_PATHS_LOCKFILE points at a lockfile outside the project directory, using + # a non-default filename to prove the override is used verbatim. + lockfile = tmp_path / "elsewhere" / "custom.lock" + lockfile.parent.mkdir() + lockfile.write_text(json.dumps(MINIMAL_LOCKFILE), encoding="utf-8") + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("RENV_PATHS_LOCKFILE", str(lockfile)) + + env = REnvironment.create(str(project)) + assert env is not None + assert env.r_version == "4.3.1" + + +def test_renv_paths_lockfile_trailing_slash_appends_renv_lock(tmp_path, monkeypatch): + # A trailing slash means "a directory"; renv.lock is appended to it. + lockdir = tmp_path / "locks" + lockdir.mkdir() + (lockdir / "renv.lock").write_text(json.dumps(MINIMAL_LOCKFILE), encoding="utf-8") + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("RENV_PATHS_LOCKFILE", str(lockdir) + "/") + + env = REnvironment.create(str(project)) + assert env is not None + assert env.r_version == "4.3.1" + + +def test_renv_paths_lockfile_relative_override_resolves_against_project(tmp_path, monkeypatch): + # A relative RENV_PATHS_LOCKFILE resolves against the project directory, not the + # current working directory (matching renv). + project = tmp_path / "project" + (project / "config").mkdir(parents=True) + (project / "config" / "custom.lock").write_text(json.dumps(MINIMAL_LOCKFILE), encoding="utf-8") + # Run from a different directory to prove resolution ignores the CWD. + workdir = tmp_path / "workdir" + workdir.mkdir() + monkeypatch.chdir(workdir) + monkeypatch.setenv("RENV_PATHS_LOCKFILE", join("config", "custom.lock")) + + env = REnvironment.create(str(project)) + assert env is not None + assert env.r_version == "4.3.1" diff --git a/tests/test_main.py b/tests/test_main.py index 6e33e989..044a634f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1158,6 +1158,37 @@ def test_no_package_json(self, tmp_path): assert "package.json" in result.output +class TestWriteManifestExcludeRenv: + def test_exclude_renv_omits_r_dependencies(self, tmp_path): + # A valid renv.lock is present but --exclude-renv must keep R dependencies + # (the manifest "platform"/"packages" keys) out of the manifest entirely. + content = tmp_path / "fastapi" + shutil.copytree(get_api_path("stock-api-fastapi", ""), str(content)) + (content / "renv.lock").write_text( + json.dumps( + { + "R": { + "Version": "4.3.1", + "Repositories": [{"Name": "CRAN", "URL": "https://cloud.r-project.org"}], + }, + "Packages": { + "R6": {"Package": "R6", "Version": "2.5.1", "Source": "Repository", "Repository": "CRAN"}, + }, + } + ) + ) + + runner = CliRunner() + result = runner.invoke( + cli, ["write-manifest", "fastapi", str(content), "--entrypoint", "main", "--exclude-renv"] + ) + assert result.exit_code == 0, result.output + + manifest = json.loads((content / "manifest.json").read_text()) + assert "platform" not in manifest + assert "packages" not in manifest + + class TestDefaultServer: def test_list_shows_default_marker(self, tmp_path): from rsconnect.metadata import ServerStore diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index ad1a442b..4aa4b963 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -819,13 +819,20 @@ def fake_get_data(package: str, resource: str): # --------------------------------------------------------------------------- +# nbconvert pulls mistune>=3.3, which uses re.Pattern[str] and is broken on +# Python 3.8; skip the jupyter-based scaffolds there. +_PY38_NBCONVERT_SKIP = pytest.mark.skipif( + sys.version_info < (3, 9), + reason="nbconvert pulls mistune>=3.3 which is broken on Python 3.8", +) + BOOT_SMOKE_MATRIX = [ pytest.param("streamlit", "http", id="streamlit"), pytest.param("shiny", "http", id="shiny"), pytest.param("fastapi", "http", id="fastapi"), pytest.param("api", "http", id="api"), - pytest.param("voila", "http", id="voila"), - pytest.param("notebook", "artifact", id="notebook"), + pytest.param("voila", "http", id="voila", marks=_PY38_NBCONVERT_SKIP), + pytest.param("notebook", "artifact", id="notebook", marks=_PY38_NBCONVERT_SKIP), pytest.param("quarto", "artifact", id="quarto"), ] diff --git a/tests/test_write_manifest_pyproject.py b/tests/test_write_manifest_pyproject.py index a2543a93..d809f3ab 100644 --- a/tests/test_write_manifest_pyproject.py +++ b/tests/test_write_manifest_pyproject.py @@ -367,6 +367,55 @@ def test_write_manifest_pyproject_unmocked_end_to_end(runner: CliRunner, project pass +# --------------------------------------------------------------------------- +# renv.lock R dependencies +# --------------------------------------------------------------------------- + + +_VALID_RENV_LOCK = json.dumps( + { + "R": {"Version": "4.3.1", "Repositories": [{"Name": "CRAN", "URL": "https://cloud.r-project.org"}]}, + "Packages": { + "R6": {"Package": "R6", "Version": "2.5.1", "Source": "Repository", "Repository": "CRAN"}, + }, + } +) + + +def test_write_manifest_pyproject_includes_r_dependencies( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """A present renv.lock adds the R platform/packages to the manifest.""" + _fake_python_environment(monkeypatch) + _write_pyproject(project_dir, _REQ_PYPROJECT) + (project_dir / "app.py").write_text("app = None\n") + (project_dir / "renv.lock").write_text(_VALID_RENV_LOCK) + + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + + assert result.exit_code == 0, result.output + manifest = _read_manifest(project_dir) + assert manifest["platform"] == "4.3.1" + assert "R6" in manifest["packages"] + + +def test_write_manifest_pyproject_exclude_renv_omits_r_dependencies( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """--exclude-renv keeps R dependencies out even when renv.lock is present.""" + _fake_python_environment(monkeypatch) + _write_pyproject(project_dir, _REQ_PYPROJECT) + (project_dir / "app.py").write_text("app = None\n") + (project_dir / "renv.lock").write_text(_VALID_RENV_LOCK) + + result = runner.invoke(cli, ["write-manifest", "pyproject", "--exclude-renv", str(project_dir)]) + + assert result.exit_code == 0, result.output + manifest = _read_manifest(project_dir) + assert "platform" not in manifest + assert "packages" not in manifest + + # --------------------------------------------------------------------------- # Overwrite guard # ---------------------------------------------------------------------------