diff --git a/azure-pipelines.yml b/azure-pipelines.yml index e7256da1..34ace234 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -26,6 +26,10 @@ jobs: Write-Host "##vso[task.prependpath]C:\Strawberry\perl\site\bin" Write-Host "##vso[task.prependpath]C:\Strawberry\c\bin" displayName: Add strawberry perl to PATH + - task: PowerShell@2 + inputs: + filePath: "testing/get-r.ps1" + displayName: install R - template: job--python-tox.yml@asottile parameters: toxenvs: [py37] @@ -42,6 +46,8 @@ jobs: testing/get-swift.sh echo '##vso[task.prependpath]/tmp/swift/usr/bin' displayName: install swift + - bash: testing/get-r.sh + displayName: install R - template: job--python-tox.yml@asottile parameters: toxenvs: [pypy3, py36, py37, py38, py39] @@ -56,3 +62,5 @@ jobs: testing/get-swift.sh echo '##vso[task.prependpath]/tmp/swift/usr/bin' displayName: install swift + - bash: testing/get-r.sh + displayName: install R diff --git a/pre_commit/languages/all.py b/pre_commit/languages/all.py index 9c2e59d7..fde6000c 100644 --- a/pre_commit/languages/all.py +++ b/pre_commit/languages/all.py @@ -16,6 +16,7 @@ from pre_commit.languages import node from pre_commit.languages import perl from pre_commit.languages import pygrep from pre_commit.languages import python +from pre_commit.languages import r from pre_commit.languages import ruby from pre_commit.languages import rust from pre_commit.languages import script @@ -52,6 +53,7 @@ languages = { 'perl': Language(name='perl', ENVIRONMENT_DIR=perl.ENVIRONMENT_DIR, get_default_version=perl.get_default_version, healthy=perl.healthy, install_environment=perl.install_environment, run_hook=perl.run_hook), # noqa: E501 'pygrep': Language(name='pygrep', ENVIRONMENT_DIR=pygrep.ENVIRONMENT_DIR, get_default_version=pygrep.get_default_version, healthy=pygrep.healthy, install_environment=pygrep.install_environment, run_hook=pygrep.run_hook), # noqa: E501 'python': Language(name='python', ENVIRONMENT_DIR=python.ENVIRONMENT_DIR, get_default_version=python.get_default_version, healthy=python.healthy, install_environment=python.install_environment, run_hook=python.run_hook), # noqa: E501 + 'r': Language(name='r', ENVIRONMENT_DIR=r.ENVIRONMENT_DIR, get_default_version=r.get_default_version, healthy=r.healthy, install_environment=r.install_environment, run_hook=r.run_hook), # noqa: E501 'ruby': Language(name='ruby', ENVIRONMENT_DIR=ruby.ENVIRONMENT_DIR, get_default_version=ruby.get_default_version, healthy=ruby.healthy, install_environment=ruby.install_environment, run_hook=ruby.run_hook), # noqa: E501 'rust': Language(name='rust', ENVIRONMENT_DIR=rust.ENVIRONMENT_DIR, get_default_version=rust.get_default_version, healthy=rust.healthy, install_environment=rust.install_environment, run_hook=rust.run_hook), # noqa: E501 'script': Language(name='script', ENVIRONMENT_DIR=script.ENVIRONMENT_DIR, get_default_version=script.get_default_version, healthy=script.healthy, install_environment=script.install_environment, run_hook=script.run_hook), # noqa: E501 diff --git a/pre_commit/languages/r.py b/pre_commit/languages/r.py new file mode 100644 index 00000000..1d42fea2 --- /dev/null +++ b/pre_commit/languages/r.py @@ -0,0 +1,141 @@ +import contextlib +import os +import shlex +import shutil +from typing import Generator +from typing import Sequence +from typing import Tuple + +from pre_commit.envcontext import envcontext +from pre_commit.envcontext import PatchesT +from pre_commit.hook import Hook +from pre_commit.languages import helpers +from pre_commit.prefix import Prefix +from pre_commit.util import clean_path_on_failure +from pre_commit.util import cmd_output_b + +ENVIRONMENT_DIR = 'renv' +get_default_version = helpers.basic_get_default_version +healthy = helpers.basic_healthy + + +def get_env_patch(venv: str) -> PatchesT: + return ( + ('R_PROFILE_USER', os.path.join(venv, 'activate.R')), + ) + + +@contextlib.contextmanager +def in_env( + prefix: Prefix, + language_version: str, +) -> Generator[None, None, None]: + envdir = _get_env_dir(prefix, language_version) + with envcontext(get_env_patch(envdir)): + yield + + +def _get_env_dir(prefix: Prefix, version: str) -> str: + return prefix.path(helpers.environment_dir(ENVIRONMENT_DIR, version)) + + +def _prefix_if_file_entry( + entry: Sequence[str], + prefix: Prefix, +) -> Sequence[str]: + if entry[1] == '-e': + return entry[1:] + else: + return (prefix.path(entry[1]),) + + +def _entry_validate(entry: Sequence[str]) -> None: + """ + Allowed entries: + # Rscript -e expr + # Rscript path/to/file + """ + if entry[0] != 'Rscript': + raise ValueError('entry must start with `Rscript`.') + + if entry[1] == '-e': + if len(entry) > 3: + raise ValueError('You can supply at most one expression.') + elif len(entry) > 2: + raise ValueError( + 'The only valid syntax is `Rscript -e {expr}`', + 'or `Rscript path/to/hook/script`', + ) + + +def _cmd_from_hook(hook: Hook) -> Tuple[str, ...]: + opts = ('--no-save', '--no-restore', '--no-site-file', '--no-environ') + entry = shlex.split(hook.entry) + _entry_validate(entry) + + return ( + *entry[:1], *opts, + *_prefix_if_file_entry(entry, hook.prefix), + *hook.args, + ) + + +def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> None: + env_dir = _get_env_dir(prefix, version) + with clean_path_on_failure(env_dir): + os.makedirs(env_dir, exist_ok=True) + path_desc_source = prefix.path('DESCRIPTION') + if os.path.exists(path_desc_source): + shutil.copy(path_desc_source, env_dir) + shutil.copy(prefix.path('renv.lock'), env_dir) + cmd_output_b( + 'Rscript', '--vanilla', '-e', + """\ + missing_pkgs <- setdiff( + "renv", unname(installed.packages()[, "Package"]) + ) + options( + repos = c(CRAN = "https://cran.rstudio.com"), + renv.consent = TRUE + ) + install.packages(missing_pkgs) + renv::activate() + renv::restore() + activate_statement <- paste0( + 'renv::activate("', file.path(getwd()), '"); ' + ) + writeLines(activate_statement, 'activate.R') + is_package <- tryCatch( + suppressWarnings( + unname(read.dcf('DESCRIPTION')[,'Type'] == "Package") + ), + error = function(...) FALSE + ) + if (is_package) { + renv::install(normalizePath('.')) + } + """, + cwd=env_dir, + ) + if additional_dependencies: + cmd_output_b( + 'Rscript', '-e', + 'renv::install(commandArgs(trailingOnly = TRUE))', + *additional_dependencies, + cwd=env_dir, + ) + + +def run_hook( + hook: Hook, + file_args: Sequence[str], + color: bool, +) -> Tuple[int, bytes]: + with in_env(hook.prefix, hook.language_version): + return helpers.run_xargs( + hook, _cmd_from_hook(hook), file_args, color=color, + ) diff --git a/pre_commit/resources/empty_template_renv.lock b/pre_commit/resources/empty_template_renv.lock new file mode 100644 index 00000000..d6e31f86 --- /dev/null +++ b/pre_commit/resources/empty_template_renv.lock @@ -0,0 +1,20 @@ +{ + "R": { + "Version": "4.0.3", + "Repositories": [ + { + "Name": "CRAN", + "URL": "https://cran.rstudio.com" + } + ] + }, + "Packages": { + "renv": { + "Package": "renv", + "Version": "0.12.5", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "5c0cdb37f063c58cdab3c7e9fbb8bd2c" + } + } +} diff --git a/pre_commit/store.py b/pre_commit/store.py index e5522ec3..187c9d35 100644 --- a/pre_commit/store.py +++ b/pre_commit/store.py @@ -189,7 +189,7 @@ class Store: LOCAL_RESOURCES = ( 'Cargo.toml', 'main.go', 'go.mod', 'main.rs', '.npmignore', 'package.json', 'pre_commit_dummy_package.gemspec', 'setup.py', - 'environment.yml', 'Makefile.PL', + 'environment.yml', 'Makefile.PL', 'renv.lock', ) def make_local(self, deps: Sequence[str]) -> str: diff --git a/testing/gen-languages-all b/testing/gen-languages-all index d9b01bd0..eb7cd701 100755 --- a/testing/gen-languages-all +++ b/testing/gen-languages-all @@ -2,9 +2,9 @@ import sys LANGUAGES = [ - 'conda', 'coursier', 'docker', 'dotnet', 'docker_image', 'fail', 'golang', - 'node', 'perl', 'pygrep', 'python', 'ruby', 'rust', 'script', 'swift', - 'system', + 'conda', 'coursier', 'docker', 'docker_image', 'dotnet', 'fail', 'golang', + 'node', 'perl', 'pygrep', 'python', 'r', 'ruby', 'rust', 'script', + 'swift', 'system', ] FIELDS = [ 'ENVIRONMENT_DIR', 'get_default_version', 'healthy', 'install_environment', diff --git a/testing/get-r.ps1 b/testing/get-r.ps1 new file mode 100644 index 00000000..e7b7b619 --- /dev/null +++ b/testing/get-r.ps1 @@ -0,0 +1,6 @@ +$dir = $Env:Temp +$urlR = "https://cran.r-project.org/bin/windows/base/old/4.0.4/R-4.0.4-win.exe" +$outputR = "$dir\R-win.exe" +$wcR = New-Object System.Net.WebClient +$wcR.DownloadFile($urlR, $outputR) +Start-Process -FilePath $outputR -ArgumentList "/S /v/qn" diff --git a/testing/get-r.sh b/testing/get-r.sh new file mode 100755 index 00000000..5d09828e --- /dev/null +++ b/testing/get-r.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +sudo apt install r-base +# create empty folder for user library. +# necessary for non-root users who have +# never installed an R package before. +# Alternatively, we require the renv +# package to be installed already, then we can +# omit that. +Rscript -e 'dir.create(Sys.getenv("R_LIBS_USER"), recursive = TRUE)' diff --git a/testing/resources/r_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/r_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 00000000..b3545d96 --- /dev/null +++ b/testing/resources/r_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,48 @@ +# parsing file +- id: parse-file-no-opts-no-args + name: Say hi + entry: Rscript parse-file-no-opts-no-args.R + language: r + types: [r] +- id: parse-file-no-opts-args + name: Say hi + entry: Rscript parse-file-no-opts-args.R + args: [--no-cache] + language: r + types: [r] +## parsing expr +- id: parse-expr-no-opts-no-args-1 + name: Say hi + entry: Rscript -e '1+1' + language: r + types: [r] +- id: parse-expr-args-in-entry-2 + name: Say hi + entry: Rscript -e '1+1' -e '3' --no-cache3 + language: r + types: [r] +# real world +- id: hello-world + name: Say hi + entry: Rscript hello-world.R + args: [blibla] + language: r + types: [r] +- id: hello-world-inline + name: Say hi + entry: | + Rscript -e + 'stopifnot( + packageVersion("rprojroot") == "1.0", + packageVersion("gli.clu") == "0.0.0.9000" + ) + cat(commandArgs(trailingOnly = TRUE), "from R!\n", sep = ", ") + ' + args: ['Hi-there'] + language: r + types: [r] +- id: additional-deps + name: Check additional deps + entry: Rscript additional-deps.R + language: r + types: [r] diff --git a/testing/resources/r_hooks_repo/DESCRIPTION b/testing/resources/r_hooks_repo/DESCRIPTION new file mode 100644 index 00000000..0e597a8a --- /dev/null +++ b/testing/resources/r_hooks_repo/DESCRIPTION @@ -0,0 +1,19 @@ +Package: gli.clu +Title: What the Package Does (One Line, Title Case) +Type: Package +Version: 0.0.0.9000 +Authors@R: + person(given = "First", + family = "Last", + role = c("aut", "cre"), + email = "first.last@example.com", + comment = c(ORCID = "YOUR-ORCID-ID")) +Description: What the package does (one paragraph). +License: `use_mit_license()`, `use_gpl3_license()` or friends to + pick a license +Encoding: UTF-8 +LazyData: true +Roxygen: list(markdown = TRUE) +RoxygenNote: 7.1.1 +Imports: + rprojroot diff --git a/testing/resources/r_hooks_repo/additional-deps.R b/testing/resources/r_hooks_repo/additional-deps.R new file mode 100755 index 00000000..bc145951 --- /dev/null +++ b/testing/resources/r_hooks_repo/additional-deps.R @@ -0,0 +1,2 @@ +suppressPackageStartupMessages(library("cachem")) +cat("OK\n") diff --git a/testing/resources/r_hooks_repo/hello-world.R b/testing/resources/r_hooks_repo/hello-world.R new file mode 100755 index 00000000..bf8d92f4 --- /dev/null +++ b/testing/resources/r_hooks_repo/hello-world.R @@ -0,0 +1,5 @@ +stopifnot( + packageVersion('rprojroot') == '1.0', + packageVersion('gli.clu') == '0.0.0.9000' +) +cat("Hello, World, from R!\n") diff --git a/testing/resources/r_hooks_repo/renv.lock b/testing/resources/r_hooks_repo/renv.lock new file mode 100644 index 00000000..d7d5fdcc --- /dev/null +++ b/testing/resources/r_hooks_repo/renv.lock @@ -0,0 +1,27 @@ +{ + "R": { + "Version": "4.0.3", + "Repositories": [ + { + "Name": "CRAN", + "URL": "https://cloud.r-project.org" + } + ] + }, + "Packages": { + "renv": { + "Package": "renv", + "Version": "0.12.5", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "5c0cdb37f063c58cdab3c7e9fbb8bd2c" + }, + "rprojroot": { + "Package": "rprojroot", + "Version": "1.0", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "86704667fe0860e4fec35afdfec137f3" + } + } +} diff --git a/tests/languages/r_test.py b/tests/languages/r_test.py new file mode 100644 index 00000000..5c046efe --- /dev/null +++ b/tests/languages/r_test.py @@ -0,0 +1,104 @@ +import os.path + +import pytest + +from pre_commit.languages import r +from testing.fixtures import make_config_from_repo +from testing.fixtures import make_repo +from tests.repository_test import _get_hook_no_install + + +def _test_r_parsing( + tempdir_factory, + store, + hook_id, + expected_hook_expr={}, + expected_args={}, +): + repo_path = 'r_hooks_repo' + path = make_repo(tempdir_factory, repo_path) + config = make_config_from_repo(path) + hook = _get_hook_no_install(config, store, hook_id) + ret = r._cmd_from_hook(hook) + expected_cmd = 'Rscript' + expected_opts = ( + '--no-save', '--no-restore', '--no-site-file', '--no-environ', + ) + expected_path = os.path.join( + hook.prefix.prefix_dir, '.'.join([hook_id, 'R']), + ) + expected = ( + expected_cmd, + *expected_opts, + *(expected_hook_expr or (expected_path,)), + *expected_args, + ) + assert ret == expected + + +def test_r_parsing_file_no_opts_no_args(tempdir_factory, store): + hook_id = 'parse-file-no-opts-no-args' + _test_r_parsing(tempdir_factory, store, hook_id) + + +def test_r_parsing_file_opts_no_args(tempdir_factory, store): + with pytest.raises(ValueError) as excinfo: + r._entry_validate(['Rscript', '--no-init', '/path/to/file']) + + msg = excinfo.value.args + assert msg == ( + 'The only valid syntax is `Rscript -e {expr}`', + 'or `Rscript path/to/hook/script`', + ) + + +def test_r_parsing_file_no_opts_args(tempdir_factory, store): + hook_id = 'parse-file-no-opts-args' + expected_args = ['--no-cache'] + _test_r_parsing( + tempdir_factory, store, hook_id, expected_args=expected_args, + ) + + +def test_r_parsing_expr_no_opts_no_args1(tempdir_factory, store): + hook_id = 'parse-expr-no-opts-no-args-1' + _test_r_parsing( + tempdir_factory, store, hook_id, expected_hook_expr=('-e', '1+1'), + ) + + +def test_r_parsing_expr_no_opts_no_args2(tempdir_factory, store): + with pytest.raises(ValueError) as execinfo: + r._entry_validate(['Rscript', '-e', '1+1', '-e', 'letters']) + msg = execinfo.value.args + assert msg == ('You can supply at most one expression.',) + + +def test_r_parsing_expr_opts_no_args2(tempdir_factory, store): + with pytest.raises(ValueError) as execinfo: + r._entry_validate( + [ + 'Rscript', '--vanilla', '-e', '1+1', '-e', 'letters', + ], + ) + msg = execinfo.value.args + assert msg == ( + 'The only valid syntax is `Rscript -e {expr}`', + 'or `Rscript path/to/hook/script`', + ) + + +def test_r_parsing_expr_args_in_entry2(tempdir_factory, store): + with pytest.raises(ValueError) as execinfo: + r._entry_validate(['Rscript', '-e', 'expr1', '--another-arg']) + + msg = execinfo.value.args + assert msg == ('You can supply at most one expression.',) + + +def test_r_parsing_expr_non_Rscirpt(tempdir_factory, store): + with pytest.raises(ValueError) as execinfo: + r._entry_validate(['AnotherScript', '-e', '{{}}']) + + msg = execinfo.value.args + assert msg == ('entry must start with `Rscript`.',) diff --git a/tests/repository_test.py b/tests/repository_test.py index da678a32..b6f7fb25 100644 --- a/tests/repository_test.py +++ b/tests/repository_test.py @@ -279,6 +279,54 @@ def test_node_hook_with_npm_userconfig_set(tempdir_factory, store, tmpdir): test_run_a_node_hook(tempdir_factory, store) +def test_r_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'r_hooks_repo', + 'hello-world', [os.devnull], + b'Hello, World, from R!\n', + ) + + +def test_r_inline_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'r_hooks_repo', + 'hello-world-inline', ['some-file'], + b'Hi-there, some-file, from R!\n', + ) + + +def test_r_with_additional_dependencies_hook(tempdir_factory, store): + _test_hook_repo( + tempdir_factory, store, 'r_hooks_repo', + 'additional-deps', [os.devnull], + b'OK\n', + config_kwargs={ + 'hooks': [{ + 'id': 'additional-deps', + 'additional_dependencies': ['cachem@1.0.4'], + }], + }, + ) + + +def test_r_local_with_additional_dependencies_hook(store): + config = { + 'repo': 'local', + 'hooks': [{ + 'id': 'local-r', + 'name': 'local-r', + 'entry': 'Rscript -e', + 'language': 'r', + 'args': ['if (packageVersion("R6") == "2.1.3") cat("OK\n")'], + 'additional_dependencies': ['R6@2.1.3'], + }], + } + hook = _get_hook(config, store, 'local-r') + ret, out = _hook_run(hook, (), color=False) + assert ret == 0 + assert _norm_out(out) == b'OK\n' + + def test_run_a_ruby_hook(tempdir_factory, store): _test_hook_repo( tempdir_factory, store, 'ruby_hooks_repo',