Skip to content

Commit 54d9104

Browse files
committed
Add support for julia hooks
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.
1 parent 9da45a6 commit 54d9104

3 files changed

Lines changed: 190 additions & 0 deletions

File tree

‎pre_commit/all_languages.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pre_commit.languages import fail
1111
from pre_commit.languages import golang
1212
from pre_commit.languages import haskell
13+
from pre_commit.languages import julia
1314
from pre_commit.languages import lua
1415
from pre_commit.languages import node
1516
from pre_commit.languages import perl
@@ -33,6 +34,7 @@
3334
'fail': fail,
3435
'golang': golang,
3536
'haskell': haskell,
37+
'julia': julia,
3638
'lua': lua,
3739
'node': node,
3840
'perl': perl,

‎pre_commit/languages/julia.py‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from __future__ import annotations
2+
3+
import contextlib
4+
from collections.abc import Generator
5+
from collections.abc import Sequence
6+
7+
from pre_commit import lang_base
8+
from pre_commit.envcontext import envcontext
9+
from pre_commit.envcontext import PatchesT
10+
from pre_commit.prefix import Prefix
11+
from pre_commit.util import cmd_output_b
12+
13+
ENVIRONMENT_DIR = 'juliaenv'
14+
health_check = lang_base.basic_health_check
15+
get_default_version = lang_base.basic_get_default_version
16+
17+
18+
def run_hook(
19+
prefix: Prefix,
20+
entry: str,
21+
args: Sequence[str],
22+
file_args: Sequence[str],
23+
*,
24+
is_local: bool,
25+
require_serial: bool,
26+
color: bool,
27+
) -> tuple[int, bytes]:
28+
# `entry` is a (hook-repo relative) file followed by (optional) args, e.g.
29+
# `bin/id.jl` or `bin/hook.jl --arg1 --arg2` so we
30+
# 1) shell parse it and join with args with hook_cmd
31+
# 2) prepend the hooks prefix path to the first argument (the file)
32+
# 3) prepend `julia` as the interpreter
33+
cmd = lang_base.hook_cmd(entry, args)
34+
cmd = ('julia', prefix.path(cmd[0]), *cmd[1:])
35+
return lang_base.run_xargs(
36+
cmd,
37+
file_args,
38+
require_serial=require_serial,
39+
color=color,
40+
)
41+
42+
43+
def get_env_patch(target_dir: str, version: str) -> PatchesT:
44+
return (
45+
# Single entry pointing to the hook env
46+
('JULIA_LOAD_PATH', target_dir),
47+
# May be set, remove it to not interfer with LOAD_PATH
48+
('JULIA_PROJECT', ''),
49+
)
50+
51+
52+
@contextlib.contextmanager
53+
def in_env(prefix: Prefix, version: str) -> Generator[None]:
54+
envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version)
55+
with envcontext(get_env_patch(envdir, version)):
56+
yield
57+
58+
59+
def install_environment(
60+
prefix: Prefix,
61+
version: str,
62+
additional_dependencies: Sequence[str],
63+
) -> None:
64+
envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version)
65+
with contextlib.ExitStack() as ctx:
66+
ctx.enter_context(in_env(prefix, version))
67+
68+
# TODO: Support language_version with juliaup similar to rust via
69+
# rustup
70+
# if version != 'system':
71+
# ...
72+
73+
# Julia code to setup and instantiate the hook environment
74+
# TODO: This would be easier to read and work with if it can be put in
75+
# a .jl file instead.
76+
julia_code = """
77+
@assert length(ARGS) > 0
78+
hook_env = ARGS[1]
79+
deps = join(ARGS[2:end], " ")
80+
81+
# Copy Project.toml to hook env
82+
mkdir(hook_env)
83+
project_names = ("JuliaProject.toml", "Project.toml")
84+
project_found = false
85+
for project_name in project_names
86+
isfile(project_name) || continue
87+
cp(project_name, joinpath(hook_env, project_name))
88+
global project_found = true
89+
break
90+
end
91+
if !project_found
92+
error("No (Julia)Project.toml found in hooks repository")
93+
end
94+
95+
# Copy Manifest.toml to hook env (not mandatory)
96+
manifest_names = ("JuliaManifest.toml", "Manifest.toml")
97+
for manifest_name in manifest_names
98+
isfile(manifest_name) || continue
99+
cp(manifest_name, joinpath(hook_env, manifest_name))
100+
break
101+
end
102+
103+
# We prepend @stdlib here so that we can load the package manager even
104+
# though `get_env_patch` limits `JULIA_LOAD_PATH` to just the hook env.
105+
pushfirst!(LOAD_PATH, "@stdlib")
106+
using Pkg
107+
popfirst!(LOAD_PATH)
108+
109+
# Instantiate the environment shipped with the hook repo. If we have
110+
# additional dependencies we disable precompilation in this step to
111+
# avoid double work.
112+
precompile = isempty(deps) ? "1" : "0"
113+
withenv("JULIA_PKG_PRECOMPILE_AUTO" => precompile) do
114+
Pkg.instantiate()
115+
end
116+
117+
# Add additional dependencies (with precompilation)
118+
if !isempty(deps)
119+
withenv("JULIA_PKG_PRECOMPILE_AUTO" => "1") do
120+
Pkg.REPLMode.pkgstr("add " * deps)
121+
end
122+
end
123+
"""
124+
cmd_output_b(
125+
'julia', '-e', julia_code, '--', envdir, *additional_dependencies,
126+
cwd=prefix.prefix_dir,
127+
)

‎tests/languages/julia_test.py‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from __future__ import annotations
2+
3+
from pre_commit.languages import julia
4+
from testing.language_helpers import run_language
5+
6+
7+
def _make_hook(tmp_path, julia_code):
8+
src_dir = tmp_path.joinpath('src')
9+
src_dir.mkdir()
10+
src_dir.joinpath('main.jl').write_text(julia_code)
11+
tmp_path.joinpath('Project.toml').write_text(
12+
'[deps]\n'
13+
'Example = "7876af07-990d-54b4-ab0e-23690620f79a"\n',
14+
)
15+
16+
17+
def test_julia_hook(tmp_path):
18+
code = """
19+
using Example
20+
function main()
21+
println("Hello, world!")
22+
end
23+
main()
24+
"""
25+
_make_hook(tmp_path, code)
26+
expected = (0, b'Hello, world!\n')
27+
assert run_language(tmp_path, julia, 'src/main.jl') == expected
28+
29+
30+
def test_julia_hook_args(tmp_path):
31+
code = """
32+
function main(argv)
33+
foreach(println, argv)
34+
end
35+
main(ARGS)
36+
"""
37+
_make_hook(tmp_path, code)
38+
expected = (0, b'--arg1\n--arg2\n')
39+
assert run_language(
40+
tmp_path, julia, 'src/main.jl --arg1 --arg2',
41+
) == expected
42+
43+
44+
def test_julia_hook_additional_deps(tmp_path):
45+
code = """
46+
using TOML
47+
function main()
48+
project_file = Base.active_project()
49+
dict = TOML.parsefile(project_file)
50+
for (k, v) in dict["deps"]
51+
println(k, " = ", v)
52+
end
53+
end
54+
main()
55+
"""
56+
_make_hook(tmp_path, code)
57+
deps = ('TOML=fa267f1f-6049-4f14-aa54-33bafae1ed76',)
58+
ret, out = run_language(tmp_path, julia, 'src/main.jl', deps=deps)
59+
assert ret == 0
60+
assert b'Example = 7876af07-990d-54b4-ab0e-23690620f79a' in out
61+
assert b'TOML = fa267f1f-6049-4f14-aa54-33bafae1ed76' in out

0 commit comments

Comments
 (0)