From 87e36189d8f61dfdc46428800141d99523f9ccb1 Mon Sep 17 00:00:00 2001 From: Jordan Bradford <36420801+jrdnbradford@users.noreply.github.com> Date: Tue, 30 Apr 2024 20:07:11 -0400 Subject: [PATCH 1/3] Add JSON schema and validation function --- NAMESPACE | 1 + R/lockfile-validate.R | 75 +++++ inst/schema/draft-07.renv.lock.schema.json | 199 +++++++++++++ man/lockfile_validate.Rd | 65 +++++ tests/testthat/test-lockfile-validate.R | 310 +++++++++++++++++++++ vignettes/faq.Rmd | 2 +- 6 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 R/lockfile-validate.R create mode 100644 inst/schema/draft-07.renv.lock.schema.json create mode 100644 man/lockfile_validate.Rd create mode 100644 tests/testthat/test-lockfile-validate.R diff --git a/NAMESPACE b/NAMESPACE index a3dbf2245..971deba23 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -20,6 +20,7 @@ export(load) export(lockfile_create) export(lockfile_modify) export(lockfile_read) +export(lockfile_validate) export(lockfile_write) export(migrate) export(modify) diff --git a/R/lockfile-validate.R b/R/lockfile-validate.R new file mode 100644 index 000000000..3ae99c81d --- /dev/null +++ b/R/lockfile-validate.R @@ -0,0 +1,75 @@ + +#' Validate the renv lockfile against a schema +#' +#' @description +#' `renv::lockfile_validate()` can be used to validate your `renv.lock` +#' against a default or custom schema. It can be used to automate checks, +#' check for obvious errors, and ensure that any custom fields you add fit +#' your specific needs. +#' +#' @details +#' See the [JSON Schema docs](https://json-schema.org/) for more information +#' on JSON schemas, their use in validation, and how to write your own schema. +#' +#' `renv::lockfile_validate()` wraps ROpenSci's +#' [`jsonvalidate`](https://docs.ropensci.org/jsonvalidate/) package, passing +#' many of its parameters to that package's `json_validate()` function. Use +#' `?jsonvalidate::json_validate` for more information. +#' +#' @param lockfile Contents of the lockfile, or a filename containing one. +#' If not provided, it defaults to the project's lockfile. +#' +#' @param schema Contents of a renv schema, or a filename containing a schema. +#' If not provided, renv's default schema is used. +#' +#' @param greedy Boolean. Continue after first error? +#' +#' @param error Boolean. Throw an error on parse failure? +#' +#' @param verbose Boolean. If `TRUE`, then an attribute `errors` will list validation failures as a `data.frame`. +#' +#' @param strict Boolean. Set whether the schema should be parsed strictly or not. +#' If in strict mode schemas will error to "prevent any unexpected behaviours or silently ignored mistakes in user schema". +#' For example it will error if encounters unknown formats or unknown keywords. +#' See https://ajv.js.org/strict-mode.html for details. +#' +#' @return Boolean. `TRUE` if validation passes. `FALSE` if validation fails. +#' +#' @examples +#' \dontrun{ +#' +#' # validate the project's lockfile +#' renv::lockfile_validate() +#' +#' # validate the project's lockfile using a non-default schema +#' renv::lockfile_validate(schema = "/path/to/your/custom/schema.json") +#' +#' # validate a lockfile using its path +#' renv::lockfile_validate(lockfile = "/path/to/your/renv.lock") +#' } +#' @export +lockfile_validate <- function(project = NULL, + lockfile = NULL, # Use default project lockfile if not provided + schema = NULL, # Use default schema if not provided + greedy = FALSE, + error = FALSE, + verbose = FALSE, + strict = FALSE) +{ + + project <- renv_project_resolve(project) + lockfile <- lockfile %||% renv_lockfile_path(project = project) + schema <- schema %||% system.file("schema", + "draft-07.renv.lock.schema.json", + package = "renv", + mustWork = TRUE) + + # "ajv" engine required for schema specifications later than draft-04 + jsonvalidate::json_validate(lockfile, + schema, + engine = "ajv", + greedy = greedy, + error = error, + verbose = verbose, + strict = strict) +} diff --git a/inst/schema/draft-07.renv.lock.schema.json b/inst/schema/draft-07.renv.lock.schema.json new file mode 100644 index 000000000..6cef1776c --- /dev/null +++ b/inst/schema/draft-07.renv.lock.schema.json @@ -0,0 +1,199 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$comment": "See https://github.com/rstudio/renv", + "title": "renv.lock file", + "description": "A schema for renv.lock files generated by {renv}", + "type": "object", + "properties": { + "R": { + "description": "Version of R used in the project", + "type": "object", + "properties": { + "Version": { + "description": "The version of R used", + "type": "string", + "examples": ["4.2.3"] + }, + "Repositories": { + "description": "The R repositories used in this project", + "type": "array", + "items": { + "type": "object", + "properties": { + "Name": { + "description": "Name of the repository", + "type": "string", + "examples": ["CRAN"] + }, + "URL": { + "description": "URL of the repository", + "type": "string", + "format": "uri", + "examples": ["https://cloud.r-project.org"] + } + }, + "required": ["Name", "URL"] + } + } + }, + "required": ["Version", "Repositories"] + }, + "Bioconductor": { + "description": "", + "type": "object", + "properties": { + "Version": { + "description": "Release of Bioconductor", + "type": "string", + "examples": ["3.18"] + } + }, + "required": ["Version"] + }, + "Python": { + "description": "Version of Python used in the project", + "type": "object", + "properties": { + "Name": { + "description": "Path to the Python environment", + "type": "string", + "examples": [ + ".venv", + "./renv/python/virtualenvs/renv-python-3.10" + ] + }, + "Type": { + "description": "Type of Python environment", + "type": "string", + "examples": ["virtualenv"] + }, + "Version": { + "description": "Version of Python required", + "type": "string", + "examples": ["3.10.12", "3.9.0"] + } + }, + "required": ["Name", "Type", "Version"] + }, + "Packages": { + "description": "Packages required by the R project", + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "Package": { + "description": "The package name", + "type": "string", + "examples": ["ggplot2", "dplyr"] + }, + "Version": { + "description": "The package version", + "type": "string", + "examples": ["1.0.0", "3.4.6"] + }, + "Source": { + "description": "The location from which this package was retrieved", + "type": "string", + "examples": [ + "Repository", + "Bioconductor", + "/mnt/r/pkg/package_name_1.0.1.tar.gz" + ] + }, + "Repository": { + "description": "The name of the repository (if any) from which this package was retrieved", + "type": "string", + "examples": ["CRAN"] + }, + "Hash": { + "description": "A unique hash for this package, used for package caching", + "type": "string", + "pattern": "^[a-fA-F0-9]{32}$", + "examples": ["06230136b2d2b9ba5805e1963fa6e890"] + }, + "biocViews": { + "description": "Bioconductor package dependencies", + "type": "string" + }, + "RemoteType": { + "description": "Type of the remote, typically written for packages installed by the devtools, remotes and pak packages", + "type": "string", + "examples": ["standard", "github"] + }, + "RemoteHost": { + "description": "Host for the remote", + "type": "string", + "format": "hostname", + "examples": ["api.github.com"] + }, + "RemoteUsername": { + "description": "Username for the remote", + "type": "string" + }, + "RemoteRepo": { + "description": "Repositories for the package", + "type": "string", + "examples": [ + "https://cran.rstudio.com", + "https://cloud.r-project.org" + ] + }, + "RemoteRepos": { + "description": "Repositories for the package", + "type": "string", + "format": "uri", + "examples": [ + "https://cran.rstudio.com", + "https://cloud.r-project.org" + ] + }, + "RemoteRef": { + "description": "Ref of the package", + "type": "string", + "examples": ["renv", "main"] + }, + "RemotePkgRef": { + "description": "Name of the packages", + "type": "string" + }, + "RemotePkgPlatform": { + "description": "Architecture/platform of the remote", + "type": "string", + "examples": ["aarch64-apple-darwin20"] + }, + "RemoteSha": { + "description": "Version number of the package", + "type": "string", + "examples": ["1763e0dcb72fb58d97bab97bb834fc71f1e012bc"] + }, + "Requirements": { + "description": "Dependencies of the package", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "R", + "jsonlite", + "lifecycle", + "magrittr", + "stringi" + ], + [ + "R6", + "Rcpp", + "later", + "magrittr", + "rlang", + "stats" + ] + ] + } + }, + "required": ["Package"] + } + } + }, + "required": ["R", "Packages"] +} diff --git a/man/lockfile_validate.Rd b/man/lockfile_validate.Rd new file mode 100644 index 000000000..69c7230fc --- /dev/null +++ b/man/lockfile_validate.Rd @@ -0,0 +1,65 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/lockfile-validate.R +\name{lockfile_validate} +\alias{lockfile_validate} +\title{Validate the renv lockfile against a schema} +\usage{ +lockfile_validate( + project = NULL, + lockfile = NULL, + schema = NULL, + greedy = FALSE, + error = FALSE, + verbose = FALSE, + strict = FALSE +) +} +\arguments{ +\item{lockfile}{Contents of the lockfile, or a filename containing one. +If not provided, it defaults to the project's lockfile.} + +\item{schema}{Contents of a renv schema, or a filename containing a schema. +If not provided, renv's default schema is used.} + +\item{greedy}{Boolean. Continue after first error?} + +\item{error}{Boolean. Throw an error on parse failure?} + +\item{verbose}{Boolean. If \code{TRUE}, then an attribute \code{errors} will list validation failures as a \code{data.frame}.} + +\item{strict}{Boolean. Set whether the schema should be parsed strictly or not. +If in strict mode schemas will error to "prevent any unexpected behaviours or silently ignored mistakes in user schema". +For example it will error if encounters unknown formats or unknown keywords. +See https://ajv.js.org/strict-mode.html for details.} +} +\value{ +Boolean. \code{TRUE} if validation passes. \code{FALSE} if validation fails. +} +\description{ +\code{renv::lockfile_validate()} can be used to validate your \code{renv.lock} +against a default or custom schema. It can be used to automate checks, +check for obvious errors, and ensure that any custom fields you add fit +your specific needs. +} +\details{ +See the \href{https://json-schema.org/}{JSON Schema docs} for more information +on JSON schemas, their use in validation, and how to write your own schema. + +\code{renv::lockfile_validate()} wraps ROpenSci's +\href{https://docs.ropensci.org/jsonvalidate/}{\code{jsonvalidate}} package, passing +many of its parameters to that package's \code{json_validate()} function. Use +\code{?jsonvalidate::json_validate} for more information. +} +\examples{ +\dontrun{ + +# validate the project's lockfile +renv::lockfile_validate() + +# validate the project's lockfile using a non-default schema +renv::lockfile_validate(schema = "/path/to/your/custom/schema.json") + +# validate a lockfile using its path +renv::lockfile_validate(lockfile = "/path/to/your/renv.lock") +} +} diff --git a/tests/testthat/test-lockfile-validate.R b/tests/testthat/test-lockfile-validate.R new file mode 100644 index 000000000..690359498 --- /dev/null +++ b/tests/testthat/test-lockfile-validate.R @@ -0,0 +1,310 @@ + +test_that("a known-good lockfile passes validation", { + + lockfile <- ' +{ + "R": { + "Version": "4.2.3", + "Repositories": [ + { + "Name": "CRAN", + "URL": "https://cloud.r-project.org" + }, + { + "Name": "BioCsoft", + "URL": "https://bioconductor.org/packages/3.8/bioc" + } + ] + }, + "Python": { + "Version": "3.10.12", + "Type": "virtualenv", + "Name": "./renv/python/virtualenvs/renv-python-3.10" + }, + "Bioconductor": { + "Version": "3.8" + }, + "Packages": { + "markdown": { + "Package": "markdown", + "Version": "1.0", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "4584a57f565dd7987d59dda3a02cfb41" + }, + "mime": { + "Package": "mime", + "Version": "0.12.1", + "Source": "GitHub", + "RemoteType": "github", + "RemoteHost": "api.github.com", + "RemoteUsername": "yihui", + "RemoteRepo": "mime", + "RemoteRef": "main", + "RemoteSha": "1763e0dcb72fb58d97bab97bb834fc71f1e012bc", + "Requirements": [ + "tools" + ], + "Hash": "c2772b6269924dad6784aaa1d99dbb86" + } + } +} +' + expect_no_error(lockfile_validate(lockfile = lockfile)) + expect_true(lockfile_validate(lockfile = lockfile)) +}) + + +test_that("a known-good lockfile with extra fields passes validation", { + # Lockfile adds a R$Nickname field not present in the schema + lockfile <- ' +{ + "R": { + "Version": "4.2.3", + "Nickname": "Shortstop Beagle", + "Repositories": [ + { + "Name": "CRAN", + "URL": "https://cloud.r-project.org" + }, + { + "Name": "BioCsoft", + "URL": "https://bioconductor.org/packages/3.8/bioc" + } + ] + }, + "Python": { + "Version": "3.10.12", + "Type": "virtualenv", + "Name": "./renv/python/virtualenvs/renv-python-3.10" + }, + "Bioconductor": { + "Version": "3.8" + }, + "Packages": { + "markdown": { + "Package": "markdown", + "Version": "1.0", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "4584a57f565dd7987d59dda3a02cfb41" + }, + "mime": { + "Package": "mime", + "Version": "0.12.1", + "Source": "GitHub", + "RemoteType": "github", + "RemoteHost": "api.github.com", + "RemoteUsername": "yihui", + "RemoteRepo": "mime", + "RemoteRef": "main", + "RemoteSha": "1763e0dcb72fb58d97bab97bb834fc71f1e012bc", + "Requirements": [ + "tools" + ], + "Hash": "c2772b6269924dad6784aaa1d99dbb86" + } + } +} +' + expect_no_error(lockfile_validate(lockfile = lockfile)) + expect_true(lockfile_validate(lockfile = lockfile)) +}) + +test_that("a custom schema file can be used for successful validation", { + # Custom schema adds a required R$Nickname field present in the lockfile + lockfile <- ' +{ + "R": { + "Version": "4.2.3", + "Nickname": "Shortstop Beagle", + "Repositories": [ + { + "Name": "CRAN", + "URL": "https://cloud.r-project.org" + } + ] + }, + "Packages": { + "markdown": { + "Package": "markdown", + "Version": "1.0", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "4584a57f565dd7987d59dda3a02cfb41" + } + } +} +' + + schema <- ' +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "R": { + "type": "object", + "properties": { + "Version": { + "type": "string" + }, + "Nickname": { + "type": "string" + } + }, + "required": ["Version", "Nickname"] + } + } +} +' + expect_no_error(lockfile_validate(lockfile = lockfile, schema = schema)) + expect_true(lockfile_validate(lockfile = lockfile, schema = schema)) +}) + +test_that("a custom schema file can be used for failed validation", { + # Custom schema adds a required R$Nickname field not present in the lockfile + lockfile <- ' +{ + "R": { + "Version": "4.2.3", + "Repositories": [ + { + "Name": "CRAN", + "URL": "https://cloud.r-project.org" + } + ] + }, + "Packages": { + "markdown": { + "Package": "markdown", + "Version": "1.0", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "4584a57f565dd7987d59dda3a02cfb41" + } + } +} +' + + schema <- ' +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "R": { + "type": "object", + "properties": { + "Version": { + "type": "string" + }, + "Nickname": { + "type": "string" + } + }, + "required": ["Version", "Nickname"] + } + } +} +' + + expect_false(lockfile_validate(lockfile = lockfile, schema = schema)) +}) + +test_that("an incorrect Packages$Hash field fails validation", { + + lockfile <- ' +{ + "R": { + "Version": "4.2.3", + "Repositories": [ + { + "Name": "CRAN", + "URL": "https://cloud.r-project.org" + } + ] + }, + "Packages": { + "markdown": { + "Package": "markdown", + "Version": "1.0", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "4584a57f565dd" + } + } +} +' + + expect_false(lockfile_validate(lockfile = lockfile)) +}) + +test_that("invalid JSON fails validation", { + # Packages uses [] which is not valid JSON + lockfile <- ' +{ + "R": { + "Version": "4.2.3", + "Repositories": [ + { + "Name": "CRAN", + "URL": "https://cloud.r-project.org" + } + ] + }, + "Packages": [ + "markdown": { + "Package": "markdown", + "Version": "1.0", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "4584a57f565dd7987d59dda3a02cfb41" + } + ] +} +' + expect_error(lockfile_validate(lockfile = lockfile, error = TRUE)) +}) + +test_that("strict mode catches unknown keyword in provided schema", { + # Custom schema provides "Version" with "type": "UNKNOWN" + lockfile <- ' +{ + "R": { + "Version": "4.2.3", + "Repositories": [ + { + "Name": "CRAN", + "URL": "https://cloud.r-project.org" + } + ] + }, + "Packages": [ + "markdown": { + "Package": "markdown", + "Version": "1.0", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "4584a57f565dd7987d59dda3a02cfb41" + } + ] +} +' + + schema <- ' +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "R": { + "type": "object", + "properties": { + "Version": { + "type": "UNKNOWN" + } + } + } + } +} +' + expect_error(lockfile_validate(lockfile = lockfile, strict = TRUE)) +}) diff --git a/vignettes/faq.Rmd b/vignettes/faq.Rmd index 3cd769b56..1817c19ca 100644 --- a/vignettes/faq.Rmd +++ b/vignettes/faq.Rmd @@ -67,7 +67,7 @@ In that sense, the "right" way to update the lockfile is to: 3. Call `renv::snapshot()` to update the lockfile. That said, you are also free to modify the `renv.lock` lockfile by hand if necessary; e.g. if you want to manually add / change repositories, change the version of a package used, and so on. -The `renv.lock` lockfile is a [JSON](https://www.json.org/json-en.html) file, and while no schema is provided, you should be able to infer the structure from the existing fields. +The `renv.lock` lockfile is a [JSON](https://www.json.org/json-en.html) file. A [JSON schema](https://json-schema.org/) is provided in the [renv repository](https://github.com/rstudio/renv/tree/main/inst/schema). The main downside to editing a package record in the lockfile directly is that you won't be able to provide a `Hash` for that package, and so renv won't be able to use its global package cache when installing that package. From bbee70a34335b0cd65bcb10630b0a4ac3afb11b6 Mon Sep 17 00:00:00 2001 From: Jordan <36420801+jrdnbradford@users.noreply.github.com> Date: Sat, 18 May 2024 10:50:32 -0400 Subject: [PATCH 2/3] Update comment wording Co-authored-by: Kevin Ushey --- R/lockfile-validate.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/lockfile-validate.R b/R/lockfile-validate.R index 3ae99c81d..4466892f9 100644 --- a/R/lockfile-validate.R +++ b/R/lockfile-validate.R @@ -1,5 +1,5 @@ -#' Validate the renv lockfile against a schema +#' Validate an renv lockfile against a schema #' #' @description #' `renv::lockfile_validate()` can be used to validate your `renv.lock` From 8816f2c2ceb708dd8eee16df5ce13e6e9f2be04c Mon Sep 17 00:00:00 2001 From: Jordan Bradford <36420801+jrdnbradford@users.noreply.github.com> Date: Sat, 18 May 2024 10:54:39 -0400 Subject: [PATCH 3/3] Add `jsonvalidate` to `DESCRIPTION` `Suggests` --- DESCRIPTION | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 19b764c02..9ffb7a7e2 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -17,8 +17,8 @@ License: MIT + file LICENSE URL: https://rstudio.github.io/renv/, https://github.com/rstudio/renv BugReports: https://github.com/rstudio/renv/issues Imports: utils -Suggests: BiocManager, cli, covr, cpp11, devtools, gitcreds, jsonlite, knitr, miniUI, - packrat, pak, R6, remotes, reticulate, rmarkdown, rstudioapi, shiny, testthat, +Suggests: BiocManager, cli, covr, cpp11, devtools, gitcreds, jsonlite, jsonvalidate, knitr, + miniUI, packrat, pak, R6, remotes, reticulate, rmarkdown, rstudioapi, shiny, testthat, uuid, waldo, yaml, webfakes Encoding: UTF-8 RoxygenNote: 7.3.1