Skip to content

Commit 7243fb7

Browse files
authored
feat: use a driver script for codefile appbundles (#53)
1 parent b06ee87 commit 7243fb7

File tree

9 files changed

+160
-20
lines changed

9 files changed

+160
-20
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
44

5+
## Unreleased
6+
7+
### Changed
8+
9+
* When submitting an appbundle with the two-argument `JuliaHub.appbundle(bundle_directory, codefile)` method, JuliaHub.jl now ensures that `@__DIR__` `@__FILE`, and `include()` in the user code now work correctly. There is a subtle behavior change due to this, where now the user script _must_ be present within the uploaded appbundle tarball (previously it was possible to use a file that would get filtered out by `.juliabundleignore`). (#37, #53)
10+
511
## Version v0.1.9 - 2024-03-13
612

713
### Fixed

docs/src/guides/jobs.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ The Julia environment in the directory is also immediately added into the bundle
7979

8080
An appbundle can be constructed with the [`appbundle`](@ref) function, which takes as arguments the path to the directory to be bundled up, and a script _within that directory_.
8181
This is meant to be used for project directories where you have your Julia environment in the top level of the directory or repository.
82-
For example, you can submit an bundle from a submit script on the top level of your project directory as follows:
82+
83+
For example, suppose you have a script at the top level of your project directory, then you can submit a bundle as follows:
8384

8485
```@example
8586
import JuliaHub # hide
@@ -98,8 +99,9 @@ When the job starts on JuliaHub, this environment is instantiated.
9899
A key feature of the appbundle is that development dependencies of the environment (i.e. packages added with `pkg> develop` or `Pkg.develop()`) are also bundled up into the archive that gets submitted to JuliaHub (including any current, uncommitted changes).
99100
Registered packages are installed via the package manager via the standard environment instantiation, and their source code is not included in the bundle directly.
100101

101-
When the JuliaHub job starts, the bundle is unpacked into the `appbundle/` directory (relative to the starting working directory).
102-
E.g. if you have a `mydata.dat` file in the bundled directory, you can access it in the script at `joinpath("appbundle", "mydata.dat")`.
102+
When the JuliaHub job starts, the working directory is set to the root of the unpacked appbundle directory.
103+
This should be kept in mind especially when launching a script that is not at the root itself, and trying to open other files from the appbundle in that script (e.g. with `open`).
104+
You can still use `@__DIR__` to load files relative to the script, and `include`s also work as expected (i.e. relative to the script file).
103105

104106
Finally, a `.juliabundleignore` file can be used to exclude certain directories, by adding the relevant [globs](https://en.wikipedia.org/wiki/Glob_(programming)), similar to how `.gitignore` files work.
105107
In addition, `.git` directories are also automatically excluded from the bundle.

src/appbundle-driver.jl

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# This in an automatically generated driver script generated by JuliaHub.jl
2+
# when submitting an appbundle.
3+
let
4+
path_components = [{PATH_COMPONENTS}]
5+
path = abspath(pwd(), path_components...)
6+
if !isfile(path)
7+
path_relative = joinpath(path_components...)
8+
error("""
9+
Unable to load requested script: $(path_relative)
10+
at $(path)""")
11+
end
12+
path
13+
end |> include

src/jobsubmission.jl

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -698,16 +698,18 @@ end
698698
Construct an appbundle-type JuliaHub batch job configuration. An appbundle is a directory containing a Julia environment
699699
that is bundled up, uploaded to JuliaHub, and then unpacked and instantiated as the job starts.
700700
701+
The primary, two-argument method will submit a job that runs a file from within the appbundle (specified by `codefile`,
702+
which must be a path relative to the root of the appbundle).
701703
The code that gets executed is read from `codefile`, which should be a path to Julia source file relative to `directory`.
702704
703705
```julia
704706
JuliaHub.appbundle(@__DIR__, "my-script.jl")
705707
```
706708
707-
Alternatively, if `codefile` is omitted, the code can be provided as a string via the `code` keyword argument.
709+
Alternatively, if `codefile` is omitted, the code must be provided as a string via the `code` keyword argument.
708710
709711
```julia
710-
JuliaHub.AppBundle(
712+
JuliaHub.appbundle(
711713
@__DIR__,
712714
code = \"""
713715
@show ENV
@@ -734,17 +736,31 @@ The following should be kept in mind about how appbundles are handled:
734736
instantiation, and their source code is not included in the bundle directly.
735737
736738
* When the JuliaHub job starts, the bundle is unpacked and the job's starting working directory
737-
is set to the `appbundle/` directory, and you can e.g. load the data from those files with
738-
just `read("my-data.txt", String)`.
739-
740-
Note that `@__DIR__` points elsewhere and, relatedly, `include` in the main script should be
741-
used with an absolute path (e.g. `include(joinpath(pwd(), "my-julia-file.jl"))`).
739+
is set to the root of the unpacked appbundle directory, and you can e.g. load the data from those
740+
files with just `read("my-data.txt", String)`.
742741
743742
!!! compat "JuliaHub 6.2 and older"
744743
745-
On some older JuliaHub versions (6.2 and older), the working directory was set to the parent
746-
directory of `appbundle/`, and so it was necessary to do `joinpath("appbundle", "mydata.dat")`
747-
to load the code.
744+
On some JuliaHub versions (6.2 and older), the working directory was set to the parent directory
745+
of unpacked appbundle (with the appbundle directory called `appbundle`), and so it was necessary
746+
to do `joinpath("appbundle", "mydata.dat")` to load files.
747+
748+
* When submitting appbundles with the two-argument `codefile` method, you can expect `@__DIR__` and
749+
`include` to work as expected.
750+
751+
However, when submitting the Julia code as a string (via the `code` keyword argument), the behavior of
752+
`@__DIR__` and `include` should be considered undefined and subject to change in the future.
753+
754+
* The one-argument + `code` keyword argument method is a lower-level method, that more closely mirrors
755+
the underlying platform API. The custom code that is passed via `code` is sometimes referred to as the
756+
"driver script", and the two-argument method is implemented by submitting an automatically
757+
constructed driver script that actually loads the specified file.
758+
759+
!!! compat "Deprecation: v0.1.10"
760+
761+
As of JuliaHub.jl v0.1.10, the ability to launch appbundles using the two-argument method where
762+
the `codefile` parameter point to a file outside of the appbundle itself, is deprecated. You can still
763+
submit the contents of the script as the driver script via the `code` keyword argument.
748764
"""
749765
function appbundle end
750766

@@ -769,14 +785,56 @@ function appbundle(
769785
end
770786

771787
function appbundle(bundle_directory::AbstractString, codefile::AbstractString; kwargs...)
772-
codefile = abspath(joinpath(bundle_directory, codefile))
773788
haskey(kwargs, :code) &&
774789
throw(ArgumentError("'code' keyword not supported if 'codefile' passed"))
775-
isfile(codefile) ||
776-
throw(ArgumentError("'codefile' does not point to an existing file: $codefile"))
777-
appbundle(bundle_directory; kwargs..., code=read(codefile, String))
790+
codefile_fullpath = abspath(bundle_directory, codefile)
791+
isfile(codefile_fullpath) ||
792+
throw(ArgumentError("'codefile' does not point to an existing file: $codefile_fullpath"))
793+
codefile_relpath = relpath(codefile_fullpath, bundle_directory)
794+
# It is possible that the user passes a `codefile` path that is outside of the appbundle
795+
# directory. This used to work back when `codefile` was just read() and submitted as the
796+
# code argument. So we still support this, but print a loud deprecation warning.
797+
if startswith(codefile_relpath, "..")
798+
@warn """
799+
Deprecated: codefile outside of the appbundle $(codefile_relpath)
800+
The support for codefiles outside of the appbundle will be removed in a future version.
801+
Also note that in this mode, the behaviour of @__DIR__, @__FILE__, and include() with
802+
a relative path are undefined.
803+
804+
To avoid the warning, but retain the old behavior, you can explicitly pass the code
805+
keyword argument instead of `codefile`:
806+
807+
JuliaHub.appbundle(
808+
bundle_directory;
809+
code = read(joinpath(bundle_directory, codefile), String),
810+
kwargs...
811+
)
812+
"""
813+
appbundle(bundle_directory; kwargs..., code=read(codefile_fullpath, String))
814+
else
815+
# TODO: we could check that codefile actually exists within the appbundle tarball
816+
# (e.g. to also catch if it is accidentally .juliabundleignored). This would require
817+
# Tar.list-ing the bundled tarball, and checking that the file is in there.
818+
driver_script = replace(
819+
_APPBUNDLE_DRIVER_TEMPLATE,
820+
"{PATH_COMPONENTS}" => _tuple_encode_path_components(codefile_relpath),
821+
)
822+
appbundle(bundle_directory; kwargs..., code=driver_script)
823+
end
778824
end
779825

826+
# We'll hard-code the file path directly into the driver script as string literals.
827+
# We trust here that repr() will take care of any necessary escaping of the path
828+
# components. In the end, we'll write the path "x/y/z" into the file as
829+
#
830+
# "x", "y", "z"
831+
#
832+
# Note: splitting up the path into components also helps avoid any cross-platform
833+
# path separator issues.
834+
_tuple_encode_path_components(path) = join(repr.(splitpath(path)), ",")
835+
836+
const _APPBUNDLE_DRIVER_TEMPLATE = read(abspath(@__DIR__, "appbundle-driver.jl"), String)
837+
780838
function _upload_appbundle(appbundle_tar_path::AbstractString; auth::Authentication)
781839
isfile(appbundle_tar_path) ||
782840
throw(ArgumentError("Appbundle file missing: $(appbundle_tar_path)"))
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
const MY_DEPENDENT_SCRIPT_1 = true

test/jobenvs/job1/script.jl

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,31 @@ toml = TOML.parsefile(projecttoml)
55
datastructures_version = toml["version"]
66

77
# Check for the appbundle file
8-
datafile = joinpath(@__DIR__, "appbundle", "datafile.txt")
8+
datafile = joinpath(@__DIR__, "datafile.txt")
99
datafile_hash = if isfile(datafile)
1010
bytes2hex(open(SHA.sha1, datafile))
1111
end
1212

13+
# Try to load dependencies with relative paths:
14+
script_include_success = try
15+
include("my-dependent-script.jl")
16+
include("subdir/my-dependent-script-2.jl")
17+
true
18+
catch e
19+
e isa SystemError || rethrow()
20+
@error "Unable to load"
21+
false
22+
end
23+
1324
results = Dict(
1425
"datastructures_version" => datastructures_version,
1526
"datafile_hash" => datafile_hash,
1627
"iswindows" => Sys.iswindows(),
28+
"scripts" => Dict(
29+
"include_success" => script_include_success,
30+
"script_1" => isdefined(Main, :MY_DEPENDENT_SCRIPT_1),
31+
"script_2" => isdefined(Main, :MY_DEPENDENT_SCRIPT_2),
32+
),
1733
)
1834

1935
@info "Storing RESULTS:\n$(results)"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
const MY_DEPENDENT_SCRIPT_2 = true

test/jobs-live.jl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,13 @@ end
305305
@test VersionNumber(results["datastructures_version"]) == v"0.17.0"
306306
@test haskey(results, "datafile_hash")
307307
@test results["datafile_hash"] == datafile_hash
308+
@test haskey(results, "scripts")
309+
let s = results["scripts"]
310+
@test s isa AbstractDict
311+
@test get(s, "include_success", nothing) === true
312+
@test get(s, "script_1", nothing) === true
313+
@test get(s, "script_2", nothing) === true
314+
end
308315
end
309316
end
310317

test/jobs.jl

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,38 @@ end
8181
end
8282
end
8383

84+
function is_valid_julia_code(code::AbstractString)
85+
try
86+
ex = Meta.parse(code)
87+
if ex.head === :incomplete
88+
@error "Incomplete Julia expression in Julia code\n$(code)" ex
89+
return false
90+
end
91+
catch exception
92+
if isa(exception, Meta.ParseError)
93+
@error "Invalid Julia code\n$(code)" exception
94+
return false
95+
end
96+
end
97+
return true
98+
end
99+
84100
@testset "JuliaHub.appbundle" begin
101+
driver_file_first_line = first(eachline(IOBuffer(JuliaHub._APPBUNDLE_DRIVER_TEMPLATE)))
85102
jobfile(path...) = joinpath(JOBENVS, "job1", path...)
86103

87104
bundle = JuliaHub.appbundle(jobfile(), "script.jl")
88105
@test isfile(bundle.environment.tarball_path)
89-
@test bundle.code == read(jobfile("script.jl"), String)
106+
@test startswith(bundle.code, driver_file_first_line)
107+
@test contains(bundle.code, "\"script.jl\"")
108+
@test is_valid_julia_code(bundle.code)
109+
110+
bundle = JuliaHub.appbundle(jobfile(), "subdir/my-dependent-script-2.jl")
111+
@test isfile(bundle.environment.tarball_path)
112+
@test startswith(bundle.code, driver_file_first_line)
113+
@test contains(bundle.code, "\"subdir\"")
114+
@test contains(bundle.code, "\"my-dependent-script-2.jl\"")
115+
@test is_valid_julia_code(bundle.code)
90116

91117
bundle = JuliaHub.appbundle(jobfile(); code="test()")
92118
@test isfile(bundle.environment.tarball_path)
@@ -116,7 +142,17 @@ end
116142
cd(jobfile()) do
117143
bundle = JuliaHub.appbundle(".", "script.jl")
118144
@test isfile(bundle.environment.tarball_path)
119-
@test bundle.code == read(jobfile("script.jl"), String)
145+
@test startswith(bundle.code, driver_file_first_line)
146+
@test contains(bundle.code, "\"script.jl\"")
147+
@test is_valid_julia_code(bundle.code)
148+
end
149+
150+
# Deprecated case, where `codefile` comes from outside of the appbundle
151+
# directory. In that case, `codefile` gets attached directly as the driver
152+
# script.
153+
let bundle = @test_logs (:warn,) JuliaHub.appbundle(jobfile(), "../job-dist/script.jl")
154+
@test isfile(bundle.environment.tarball_path)
155+
@test bundle.code == read(jobfile("../job-dist/script.jl"), String)
120156
end
121157
end
122158

0 commit comments

Comments
 (0)