Skip to content

Commit

Permalink
Add support for julia hooks
Browse files Browse the repository at this point in the history
This patch adds 2nd class support for hooks using julia as the language.
pre-commit will install any dependencies defined in the hooks repo
`Project.toml` file, with support for `additional_dependencies` as well.
Julia doesn't (yet) have a way to install binaries/scripts so for julia
hooks the `entry` value is a (relative) path to a julia script within
the hooks repository. When executing a julia hook the (globally
installed) julia interpreter is prepended to the entry.

Example `.pre-commit-hooks.yaml`:

```yaml
- id: foo
  name: ...
  language: julia
  entry: bin/foo.jl --arg1
```

Example hooks repo: https://github.com/fredrikekre/runic-pre-commit/tree/fe/julia
Accompanying pre-commit.com PR: pre-commit/pre-commit.com#998

Fixes pre-commit#2689.
  • Loading branch information
fredrikekre committed Nov 3, 2024
1 parent 9da45a6 commit 54d9104
Show file tree
Hide file tree
Showing 3 changed files with 190 additions and 0 deletions.
2 changes: 2 additions & 0 deletions pre_commit/all_languages.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pre_commit.languages import fail
from pre_commit.languages import golang
from pre_commit.languages import haskell
from pre_commit.languages import julia
from pre_commit.languages import lua
from pre_commit.languages import node
from pre_commit.languages import perl
Expand All @@ -33,6 +34,7 @@
'fail': fail,
'golang': golang,
'haskell': haskell,
'julia': julia,
'lua': lua,
'node': node,
'perl': perl,
Expand Down
127 changes: 127 additions & 0 deletions pre_commit/languages/julia.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
from __future__ import annotations

import contextlib
from collections.abc import Generator
from collections.abc import Sequence

from pre_commit import lang_base
from pre_commit.envcontext import envcontext
from pre_commit.envcontext import PatchesT
from pre_commit.prefix import Prefix
from pre_commit.util import cmd_output_b

ENVIRONMENT_DIR = 'juliaenv'
health_check = lang_base.basic_health_check
get_default_version = lang_base.basic_get_default_version


def run_hook(
prefix: Prefix,
entry: str,
args: Sequence[str],
file_args: Sequence[str],
*,
is_local: bool,
require_serial: bool,
color: bool,
) -> tuple[int, bytes]:
# `entry` is a (hook-repo relative) file followed by (optional) args, e.g.
# `bin/id.jl` or `bin/hook.jl --arg1 --arg2` so we
# 1) shell parse it and join with args with hook_cmd
# 2) prepend the hooks prefix path to the first argument (the file)
# 3) prepend `julia` as the interpreter
cmd = lang_base.hook_cmd(entry, args)
cmd = ('julia', prefix.path(cmd[0]), *cmd[1:])
return lang_base.run_xargs(
cmd,
file_args,
require_serial=require_serial,
color=color,
)


def get_env_patch(target_dir: str, version: str) -> PatchesT:
return (
# Single entry pointing to the hook env
('JULIA_LOAD_PATH', target_dir),
# May be set, remove it to not interfer with LOAD_PATH
('JULIA_PROJECT', ''),
)


@contextlib.contextmanager
def in_env(prefix: Prefix, version: str) -> Generator[None]:
envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version)
with envcontext(get_env_patch(envdir, version)):
yield


def install_environment(
prefix: Prefix,
version: str,
additional_dependencies: Sequence[str],
) -> None:
envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version)
with contextlib.ExitStack() as ctx:
ctx.enter_context(in_env(prefix, version))

# TODO: Support language_version with juliaup similar to rust via
# rustup
# if version != 'system':
# ...

# Julia code to setup and instantiate the hook environment
# TODO: This would be easier to read and work with if it can be put in
# a .jl file instead.
julia_code = """
@assert length(ARGS) > 0
hook_env = ARGS[1]
deps = join(ARGS[2:end], " ")
# Copy Project.toml to hook env
mkdir(hook_env)
project_names = ("JuliaProject.toml", "Project.toml")
project_found = false
for project_name in project_names
isfile(project_name) || continue
cp(project_name, joinpath(hook_env, project_name))
global project_found = true
break
end
if !project_found
error("No (Julia)Project.toml found in hooks repository")
end
# Copy Manifest.toml to hook env (not mandatory)
manifest_names = ("JuliaManifest.toml", "Manifest.toml")
for manifest_name in manifest_names
isfile(manifest_name) || continue
cp(manifest_name, joinpath(hook_env, manifest_name))
break
end
# We prepend @stdlib here so that we can load the package manager even
# though `get_env_patch` limits `JULIA_LOAD_PATH` to just the hook env.
pushfirst!(LOAD_PATH, "@stdlib")
using Pkg
popfirst!(LOAD_PATH)
# Instantiate the environment shipped with the hook repo. If we have
# additional dependencies we disable precompilation in this step to
# avoid double work.
precompile = isempty(deps) ? "1" : "0"
withenv("JULIA_PKG_PRECOMPILE_AUTO" => precompile) do
Pkg.instantiate()
end
# Add additional dependencies (with precompilation)
if !isempty(deps)
withenv("JULIA_PKG_PRECOMPILE_AUTO" => "1") do
Pkg.REPLMode.pkgstr("add " * deps)
end
end
"""
cmd_output_b(
'julia', '-e', julia_code, '--', envdir, *additional_dependencies,
cwd=prefix.prefix_dir,
)
61 changes: 61 additions & 0 deletions tests/languages/julia_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from __future__ import annotations

from pre_commit.languages import julia
from testing.language_helpers import run_language


def _make_hook(tmp_path, julia_code):
src_dir = tmp_path.joinpath('src')
src_dir.mkdir()
src_dir.joinpath('main.jl').write_text(julia_code)
tmp_path.joinpath('Project.toml').write_text(
'[deps]\n'
'Example = "7876af07-990d-54b4-ab0e-23690620f79a"\n',
)


def test_julia_hook(tmp_path):
code = """
using Example
function main()
println("Hello, world!")
end
main()
"""
_make_hook(tmp_path, code)
expected = (0, b'Hello, world!\n')
assert run_language(tmp_path, julia, 'src/main.jl') == expected


def test_julia_hook_args(tmp_path):
code = """
function main(argv)
foreach(println, argv)
end
main(ARGS)
"""
_make_hook(tmp_path, code)
expected = (0, b'--arg1\n--arg2\n')
assert run_language(
tmp_path, julia, 'src/main.jl --arg1 --arg2',
) == expected


def test_julia_hook_additional_deps(tmp_path):
code = """
using TOML
function main()
project_file = Base.active_project()
dict = TOML.parsefile(project_file)
for (k, v) in dict["deps"]
println(k, " = ", v)
end
end
main()
"""
_make_hook(tmp_path, code)
deps = ('TOML=fa267f1f-6049-4f14-aa54-33bafae1ed76',)
ret, out = run_language(tmp_path, julia, 'src/main.jl', deps=deps)
assert ret == 0
assert b'Example = 7876af07-990d-54b4-ab0e-23690620f79a' in out
assert b'TOML = fa267f1f-6049-4f14-aa54-33bafae1ed76' in out

0 comments on commit 54d9104

Please sign in to comment.