-
Notifications
You must be signed in to change notification settings - Fork 6
/
Copy pathhijack.jl
593 lines (512 loc) · 23.6 KB
/
hijack.jl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
"""
ReTest.load(testpath::AbstractString;
parentmodule::Module=Main, [revise::Bool])
Include file `testpath` into `parentmodule`. If `revise` is `true`, `Revise`,
which must be loaded beforehand in your Julia session, is used to track all
recursively included files (in particular testsets). The `revise` keyword
defaults to `true` when `Revise` is loaded and `VERSION >= v"1.5"`, and to
`false` otherwise.
The point of using this function is when `revise` is `true` and in particular
when files are included recursively.
If `revise` is false, this is equivalent to `parentmodule.include(testpath)`,
and if there are no recursively included files, this should be equivalent
to `Revise.includet(testpath)`, provided `parentmodule == Main` and
all `@testset`s defined in `testpath` are in a module defining
`__revise_mode__ = :eval`.
!!! compat "Julia 1.5"
This function requires at least Julia 1.5 when `revise` is `true`.
"""
function load(testpath::AbstractString;
parentmodule::Module=Main, revise::Maybe{Bool}=nothing)
revise === true && VERSION < v"1.5" &&
error("the `revise` keyword requires at least Julia 1.5")
Revise = get_revise(revise)
if Revise === nothing
Base.include(parentmodule, testpath)
else
files = Dict{String,Module}(testpath => parentmodule)
substitute!(x) = substitute_retest!(x, false, false, files; ishijack=false)
res = Base.include(substitute!, parentmodule, testpath)
revise_track(Revise, files)
res
end
end
"""
ReTest.load(Mod::Module, testfile::AbstractString="ModTests.jl";
parentmodule::Module=Main, [revise::Bool])
Given a package `Mod`, include into `parentmodule`
the corresponding tests from file `testfile`, which is assumed to be
located in the "test" directory of the package.
It is expected that a unique new test module is created within `parentmodule`
after the inclusion, which is then returned.
Otherwise, the list of all newly created test modules is returned, triggering
a warning if it's empty.
If `revise` is `true`, `Revise`, which must be loaded beforehand in your Julia
session, is used to track the test files (in particular testsets). Note that
this might be brittle, and it's recommended instead to load your test module
via `using ModTests`. The `revise` keyword defaults to `true` when `Revise` is
loaded and `VERSION >= v"1.5"`, and to `false` otherwise.
!!! compat "Julia 1.5"
This function requires at least Julia 1.5 when `revise` is `true`.
"""
function load(packagemod::Module, testfile::Maybe{AbstractString}=nothing;
parentmodule::Module=Main, revise::Maybe{Bool}=nothing,
maybe::Bool=false)
newly_loaded = Module[]
packagepath = pathof(packagemod)
if packagepath === nothing
maybe ? (return newly_loaded) : error("$packagemod is not a package")
end
default_testfile = string(packagemod, "Tests.jl")
testfile = something(testfile, default_testfile)
testpath = joinpath(dirname(dirname(packagepath)), "test", testfile)
if !isfile(testpath)
maybe ? (return newly_loaded) : error("file $testpath does not exist")
end
old_loaded = copy(update_TESTED_MODULES!())
load(testpath; parentmodule=parentmodule, revise=revise)
newly_loadedpars = parentmodules.(setdiff(update_TESTED_MODULES!(), old_loaded))
for lpars in newly_loadedpars
idx = findlast(==(parentmodule), lpars)
if idx === nothing
@warn "test module $(first(lpars)) was defined but is not a (recursive) submodule of $parentmodule"
elseif idx == 1
@warn "testsets were added directly into module $parentmodule"
else
root = lpars[idx-1]
root ∈ newly_loaded || push!(newly_loaded, root)
end
end
if testfile == default_testfile
loaded_testmodules[packagemod] = newly_loaded
end
if !maybe && length(newly_loaded) == 1
newly_loaded[1]
else
isempty(newly_loaded) &&
@warn "test file $testpath loaded but it did not define any test module within $parentmodule"
newly_loaded
end
end
const loaded_testmodules = Dict{Module,Vector{Module}}()
"""
ReTest.hijack(source, [modname];
parentmodule::Module=Main, lazy=false, [include::Symbol],
[revise::Bool])
Given test files defined in `source` using the `Test` package, try to load
them by replacing `Test` with `ReTest`, wrapping them in a module `modname`
defined within `parentmodule`. If successful, the newly created module
`modname` is returned and `modname.runtests()` should be callable.
If `source::AbstractString`, then it's interpreted as the top level test file
(possibly including other files), and `modname` defaults to a name based
on `basename(source)`.
If `source::Module`, then it's interpreted as the name of a package, and the
"test/runtests.jl" file from this package is loaded. In this case, `modname`
defaults to `Symbol(source, :Tests)`.
The current procedure is as follows:
1. replace toplevel `using Test` occurrences by `using ReTest` (`using` can
have multiple arguments);
2. apply recursively these two rules:
* for all `include`d files, provided the `include` statement is at the toplevel,
or nested within these toplevel constructs:
`begin`, `let`, `for`, `while`, `if`, `try`;
* on the content of all included modules.
When `source` is `Base` or a standard library module, a slightly different
mechanism is used to find test files (which can contain e.g. non-toplevel
`include`s), i.e. `ReTest.hijack_base` is used underneath.
#### `lazy` keyword
The `lazy` keyword specifies whether some toplevel expressions should be skipped:
* `false` means nothing is skipped;
* `true` means toplevel `@test*` macros are removed, as well as those defined
within these toplevel (but possible nested) blocks:
`begin`, `let`, `for`, `while`, `if`, `try`;
* `:brutal` means toplevel `@test*` macros are removed, as well as toplevel
`begin`, `let`, `for` or `if` blocks.
#### `include` keyword
The `include` keyword can help to handle the case where `@testset`s contain
`include` expressions, like in the following example:
```julia
@testset "parent" begin
@test true
include("file_with_other_testsets.jl")
end
```
This works well with `Test` because testsets are run immediately, as well as
testsets contained in the included files, which are also recognized as children
of the testset which include them. With `ReTest`, the `include` expressions
would be evaluated only when the parent testsets are run, so that included
testsets are not run themselves, but only "declared".
If the `include` keyword is set to `:static`, `include(...)` expressions are
evaluated when `@testset` expressions containing them are parsed, before
filtering and before testsets are run. Testsets which are declared (within the
same module) as a side effect of `include(...)` are then inserted in place of
the call to `include(...)`.
If the `include` keyword is set to `:outline`, `hijack` inspects topelevel
`@testset` expressions and puts toplevel `include(...)` expressions outside of
the containing testset, and should therefore be evaluated immediately. This is
not ideal, but at least allows `ReTest` to know about all the testsets right
after the call to `hijack`, and to not declare new testsets when parent
testsets are run.
The `:outline` option might be deprecated in the future, and `include=:static`
should generally be preferred. One case where `:outline` might work better is
when the included file defines a submodule: `ReTest` doesn't have the concept
of a nested testset belonging to a different module than the parent testset,
so the best that can be done here is to "outline" such nested testsets; with
`include=:outline`, `hijack` will "process" the content of such submodules
(replace `using Test` by `using ReTest`, etc.), whereas with
`include=:static`, the subdmodules will get defined after `hijack` has
returned (on the first call to `retest` thereafter), so won't be "processed".
#### `revise` keyword
The `revise` keyword specifies whether `Revise` should be used to track
the test files (in particular the testsets). If `true`, `Revise` must
be loaded beforehand in your Julia session. Note that this might be brittle
and not work in all cases. `revise` defaults to `true` when `Revise` is loaded,
and to `false` otherwise.
!!! compat "Julia 1.5"
This function requires at least Julia 1.5.
"""
function hijack end
# TODO 0.4: remove `testset` kwarg
# TODO: maybe deprecate `include=:outline`?
function hijack(path::AbstractString, modname=nothing; parentmodule::Module=Main,
lazy=false, revise::Maybe{Bool}=nothing,
include::Maybe{Symbol}=nothing, testset::Bool=false)
# do first, to error early if necessary
Revise = get_revise(revise)
if modname === nothing
modname = replace(splitext(basename(path))[1], ['-', '.'] => '_')
end
modname = Symbol(modname)
newmod = @eval parentmodule module $modname end
populate_mod!(newmod, path; lazy=lazy, include=setinclude(include, testset),
Revise=Revise)
newmod
end
function setinclude(include, testset)
if testset
include === nothing || error("cannot specify both `testset` and `include` arguments")
:outline
else
include === nothing || include === :static || include === :outline ||
error("`include` keyword only accepts `:static` or `:outline` as value")
include
end
end
# this is just a work-around for v"1.5", where @__MODULE__ can't be used in
# expressions; root_module[] is set equal to @__MODULE__ within modules
const root_module = Ref{Symbol}()
__init__() = root_module[] = gensym("MODULE")
function populate_mod!(mod::Module, path; lazy, Revise, include)
lazy ∈ (true, false, :brutal) ||
throw(ArgumentError("the `lazy` keyword must be `true`, `false` or `:brutal`"))
files = Revise === nothing ? nothing : Dict(path => mod)
substitute!(x) = substitute_retest!(x, lazy, include, files)
@eval mod begin
using ReTest # for files which don't have `using Test`
const $(root_module[]) = $mod
include($substitute!, $path)
if !@isdefined __revise_mode__
__revise_mode__ = :eval
end
end
if Revise !== nothing
revise_track(Revise, files)
end
end
function revise_track(Revise, files)
# uniquemod serves in v1.5 to make hijack w/ revise still work in many cases,
# when there aren't nested submodules/includes
for (filepath, mod) in files
if isfile(filepath) # some files might not exist when they are conditionally
# included
Revise.track(mod, filepath)
end
end
end
function hijack(packagemod::Module, modname=nothing; parentmodule::Module=Main,
lazy=false, revise::Maybe{Bool}=nothing,
include::Maybe{Symbol}=nothing, testset::Bool=false)
packagepath = pathof(packagemod)
packagepath === nothing && packagemod !== Base &&
throw(ArgumentError("$packagemod is not a package"))
if modname === nothing
modname = Symbol(packagemod, :Tests)
end
if packagemod === Base
hijack_base(:_Base_tests,
base=modname, parentmodule=parentmodule, lazy=lazy, revise=revise)
elseif startswith(packagepath, Sys.STDLIB) # packagemod is an STDLIB
hijack_base(string(packagemod), modname,
parentmodule=parentmodule, lazy=lazy, revise=revise)
else
path = joinpath(dirname(dirname(packagepath)), "test", "runtests.jl")
hijack(path, modname, parentmodule=parentmodule,
lazy=lazy, testset=testset, include=include, revise=revise)
end
end
function substitute_retest!(ex, lazy, include_, files=nothing;
ishijack::Bool=true)
substitute!(x) = substitute_retest!(x, lazy, include_, files, ishijack=ishijack)
if Meta.isexpr(ex, :using)
ishijack || return ex
for used in ex.args
if Meta.isexpr(used, :., 1) && used.args[1] == :ReTest
throw(ArgumentError("ReTest is already used"))
elseif Meta.isexpr(used, :., 1) && used.args[1] == :Test
used.args[1] = :ReTest
end
end
elseif Meta.isexpr(ex, :call) && ex.args[1] == :include
if length(ex.args) > 2
throw(ArgumentError("cannot handle include with two arguments"))
else
if files !== nothing
newfile = ex.args[2]
ex2 =
:(let newfile = $_include_dependency($newfile)
haskey($files, newfile) &&
error("file belong to multiple modules, currently not supported")
include($substitute!, $newfile)
$files[newfile] = $(root_module[])
end)
if VERSION >= v"1.6"
# v1.5 doesn't play well with @__MODULE__, the let expression
# simply... vanishes; so we have to use root_module instead
push!(ex2.args[2].args, :(@assert $(root_module[]) == @__MODULE__))
end
# TODO: add `copy!(::Expr, ::Expr)` to Base
ex.head = ex2.head
copy!(ex.args, ex2.args)
else
insert!(ex.args, 2, substitute!)
end
end
elseif Meta.isexpr(ex, :module)
body = ex.args[3]
@assert Meta.isexpr(body, :block)
substitute!.(body.args)
insert!(body.args, 2, :(const $(root_module[]) = $(ex.args[2])))
# ^^^ we insert between two LineNumberNode, not sure if this is the way: 1st one
# seems to relate to the `module` line, while the 2nd relates to next expression
push!(body.args, :(if !@isdefined __revise_mode__
__revise_mode__ = :eval
end))
elseif Meta.isexpr(ex, :macrocall)
ishijack || return ex
if lazy != false && ex.args[1] ∈ TEST_MACROS
empty_expr!(ex)
elseif include_ !== nothing && ex.args[1] == Symbol("@testset")
if include_ === :outline
# we remove `include` expressions and put them out of the `@testset`
body = ex.args[end]
if body.head == :for
body = body.args[end]
end
includes = splice!(body.args, findall(body.args) do x
Meta.isexpr(x, :call) && x.args[1] == :include
end)
map!(substitute!, includes, includes)
ex.head = :block
newts = Expr(:macrocall, ex.args...)
push!(empty!(ex.args), newts, includes...)
else # :static
pos = ex.args[2] isa LineNumberNode ? 3 : 2
insert!(ex.args, pos, :(static_include=true))
end
end
elseif ex isa Expr && ex.head ∈ (:block, :let, :for, :while, :if, :try)
if lazy == :brutal
empty_expr!(ex)
else
beg = ex.head ∈ (:block, :try) ? 1 : 2
for x in ex.args[beg:end]
substitute!(x)
end
end
end
ex
end
function empty_expr!(ex)
ex.head = :block
empty!(ex.args)
ex
end
# from Base, but without touching _track_dependencies
# this allows to get the absolute path from within included files,
# and to update the list of tracked files
# - bare abspath won't work directly, as it's based on CWD
# - @__DIR__ won't work as it will refer to this file if used bare,
# and if constructed via `Expr(:macrocall, ...)`, a LineNumberNode
# is required to be passed, which defeats the purpose
function _include_dependency(_path::AbstractString)
prev = Base.source_path(nothing)
if prev === nothing
abspath(_path)
else
normpath(joinpath(dirname(prev), _path))
end
end
const TEST_MACROS = Symbol.(["@test", "@test_throws", "@test_broken", "@test_skip",
"@test_warn", "@test_nowarn", "@test_logs",
"@test_deprecated", "@inferred"])
"""
hijack_base(tests, [modname];
parentmodule::Module=Main, lazy=false, [revise::Bool])
Similar to `ReTest.hijack`, but specifically for `Base` and stdlib tests.
`tests` speficies which test files should be loaded, in the exact same format
as `Base.runtests` (i.e. it uses the same `choosetests` function to
select the tests).
Tests corresponding to a "test/[somedir/]sometest.jl" file are loaded in the
`BaseTests.[somedir.]sometest` module (if `sometest` is defined in `Base`,
then `sometest_` is used instead).
Tests corresponding to a standard library `Lib` are loaded in the
`StdLibTests.Lib_` module. When there are "testgroups", submodules
are created accordingly.
If `modname` is specified (experimental), this will be the name of the module
in which testsets are defined; passing `modname` is allowed only when all
the loaded tests would otherwise be defined in the same second top-most
module, the one under `BaseTests` or `StdLibTests` (e.g. `somedir` if any,
or `sometest` otherwise, or `Lib_`). This unique module is then named `modname`,
and not enclosed within `BaseTests` or `StdLibTests`.
The `lazy` and `revise` keywords have the same meaning as in [`ReTest.hijack`](@ref).
Depending on the value of `lazy`, some test files are skipped when they
are known to fail.
!!! compat "Julia 1.5"
This function requires at least Julia 1.5.
"""
function hijack_base(tests, modname=nothing; parentmodule::Module=Main, lazy=false,
base=:BaseTests, stdlib=:StdLibTests, revise::Maybe{Bool}=nothing)
Revise = get_revise(revise)
if isa(tests, AbstractString)
tests = split(tests)
end
tests, = ChooseTests.call_choosetests(tests) # TODO: handle other return values?
allcomponents = (components -> Symbol.(components)).(split.(tests, '/'))
if modname !== nothing
modname = Symbol(modname)
allequal(components[1] for components in allcomponents) ||
throw(ArgumentError("can't specify `modname` for multiple test roots"))
# if modname was passed, it seems nicer to have it fresh, otherwise it might
# contain left-overs from previous incantations (and potentially lead to
# a series of "WARNING: replacing module submodule_i")
@eval parentmodule module $modname end
end
for (test, components) in zip(tests, allcomponents)
if test ∈ ChooseTests.BLACKLIST ||
lazy == true && test ∈ ChooseTests.BLACKLIST_SOFT ||
lazy == :brutal && test ∈ ChooseTests.BLACKLIST_BRUTAL
@warn "skipping \"$test\" test (incompatible with ReTest)"
continue
end
if test ∈ ["show"] && !isdefined(Main, :Distributed)
@eval Main using Distributed
end
if modname === nothing
if string(components[1]) in ChooseTests.STDLIBS
# it's an stdlib Lib, make toplevel modules StdLibTests/Lib_
components[1] = Symbol(components[1], :_)
pushfirst!(components, Symbol(stdlib))
else
# it's a Base test, use BaseTests as toplevel module
pushfirst!(components, Symbol(base))
end
else
components[1] = modname
end
mod = parentmodule
for (ith, comp) in enumerate(components)
if isdefined(Base, comp) || comp ∈ ChooseTests.EPONYM_TESTS
# e.g. `tuple`, collision betwen the tuple function and test/tuple.jl
comp = Symbol(comp, :_)
end
if isdefined(mod, comp) && ith != length(components) ||
modname !== nothing && ith == 1 # module already freshly created
mod = getfield(mod, comp)
else
# we always re-eval leaf-modules
mod = @eval mod module $comp end
end
end
@eval mod begin
using Random
end
populate_mod!(mod, ChooseTests.test_path(test), lazy=lazy, Revise=Revise, testset=false)
end
end
get_revise(revise) =
if revise === true || revise === nothing && VERSION >= v"1.5"
Revise = get(Base.loaded_modules, revise_pkgid(), nothing)
Revise === nothing && revise === true &&
error("Revise is not loaded")
Revise
end
module ChooseTests
# choosetests.jl requires the Sockets package, which is why it's a ReTest dependency
const BASETESTPATH = joinpath(Sys.BINDIR, "..", "share", "julia", "test")
# we include "choosetests.jl" on the fly, because the computation of the
# STDLIBS constant in this file currently doubles the load time of ReTest
# (quite negligible though, takes about 0.005s)
function call_choosetests(choices)
isdefined(ChooseTests, :STDLIBS) ||
include(joinpath(BASETESTPATH, "choosetests.jl"))
if choices === :_Base_tests
# we compute choices lazily, as in recent Julia versions, TESTNAMES is defined
# globally in choosetests.jl, and therefore defined only after the include above
choices = filter(x -> x != "stdlib", TESTNAMES)
end
Base.invokelatest(choosetests, choices)
end
# adapted from julia/tests/runtests.jl
function test_path(test)
t = split(test, '/')
if t[1] in STDLIBS
if length(t) == 2
return joinpath(STDLIB_DIR, t[1], "test", "$(t[2]).jl")
else
return joinpath(STDLIB_DIR, t[1], "test", "runtests.jl")
end
else
return joinpath(BASETESTPATH, "$test.jl")
end
end
if VERSION < v"1.8.0-DEV.34"
const TESTNAMES = [
"subarray", "core", "compiler", "worlds",
"keywordargs", "numbers", "subtype",
"char", "strings", "triplequote", "unicode", "intrinsics",
"dict", "hashing", "iobuffer", "staged", "offsetarray",
"arrayops", "tuple", "reduce", "reducedim", "abstractarray",
"intfuncs", "simdloop", "vecelement", "rational",
"bitarray", "copy", "math", "fastmath", "functional", "iterators",
"operators", "ordering", "path", "ccall", "parse", "loading", "gmp",
"sorting", "spawn", "backtrace", "exceptions",
"file", "read", "version", "namedtuple",
"mpfr", "broadcast", "complex",
"floatapprox", "stdlib", "reflection", "regex", "float16",
"combinatorics", "sysinfo", "env", "rounding", "ranges", "mod2pi",
"euler", "show", "client",
"errorshow", "sets", "goto", "llvmcall", "llvmcall2", "ryu",
"some", "meta", "stacktraces", "docs",
"misc", "threads", "stress", "binaryplatforms", "atexit",
"enums", "cmdlineargs", "int", "interpreter",
"checked", "bitset", "floatfuncs", "precompile",
"boundscheck", "error", "ambiguous", "cartesian", "osutils",
"channels", "iostream", "secretbuffer", "specificity",
"reinterpretarray", "syntax", "corelogging", "missing", "asyncmap",
"smallarrayshrink", "opaque_closure"
]
end
const BLACKLIST = [
# failing at load time (in hijack_base)
"backtrace", "misc", "threads", "cmdlineargs","boundscheck",
"SharedArrays", # problem with workers (should be sortable out)
"Test",
# failing at runtime (in retest)
"precompile", # no toplevel testset, just one with interpolated subject
]
const BLACKLIST_BRUTAL = split("core worlds bitarray file reflection errorshow asyncmap
opaque_closure") # llvmcall: doesn't work if loaded twice
const BLACKLIST_SOFT = split("core worlds offsetarray spawn file errorshow")
# file names which define a global variable of the same name as the file
const EPONYM_TESTS = [:worlds, :file]
end # ChooseTests