Skip to content

Commit f9cf56c

Browse files
Filter testitems from AST before eval to reduce latency (#170)
* Filter testitems from AST before `eval` * Remove previous filtering code * TEMP comment out test with TODO comment * WIP3 * WIP * Revert "Remove previous filtering code" This reverts commit a61eb98. * tidying * Refactor to a single callable type for filtering * refactor into smaller functions * Add tests * Add more tests * fixup! Add more tests * fixup! Add more tests * fixup! Add more tests * Tighten usage check to not allow any top-level macrocall * another test * fixup! another test * Require String literal name, and Vector{Symbol} tags * fixup! Tighten usage check to not allow any top-level macrocall * fixup! Require String literal name, and Vector{Symbol} tags
1 parent ea1cbf8 commit f9cf56c

17 files changed

+382
-94
lines changed

src/ReTestItems.jl

Lines changed: 16 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ include("macros.jl")
6060
include("junit_xml.jl")
6161
include("testcontext.jl")
6262
include("log_capture.jl")
63+
include("filtering.jl")
6364

6465
function __init__()
6566
DEFAULT_STDOUT[] = stdout
@@ -208,25 +209,11 @@ will be run.
208209
"""
209210
function runtests end
210211

211-
# We assume copy-pasting test name would be the most common use-case
212-
_shouldrun(name::AbstractString, ti_name) = name == ti_name
213-
# Regex used for partial matches on test item name (commonly used with XUnit.jl)
214-
_shouldrun(pattern::Regex, ti_name) = contains(ti_name, pattern)
215-
_shouldrun(tags::AbstractVector{Symbol}, ti_tags) = issubset(tags, ti_tags)
216-
# All tags must be present in the test item, aka we use AND chaining for tags.
217-
# OR chaining would make it hard to run more specific subsets of tests + one can run
218-
# mutliple independents `runtests` with AND chaining to get most of the benefits of OR chaining
219-
# (with the caveat that overlaps would be run multiple times)
220-
_shouldrun(tag::Symbol, ti_tags) = tag in ti_tags
221-
_shouldrun(::Nothing, x) = true
222-
223-
default_shouldrun(ti::TestItem) = true
224-
225-
runtests(; kw...) = runtests(default_shouldrun, dirname(Base.active_project()); kw...)
212+
runtests(; kw...) = runtests(Returns(true), dirname(Base.active_project()); kw...)
226213
runtests(shouldrun; kw...) = runtests(shouldrun, dirname(Base.active_project()); kw...)
227-
runtests(paths::AbstractString...; kw...) = runtests(default_shouldrun, paths...; kw...)
214+
runtests(paths::AbstractString...; kw...) = runtests(Returns(true), paths...; kw...)
228215

229-
runtests(pkg::Module; kw...) = runtests(default_shouldrun, pkg; kw...)
216+
runtests(pkg::Module; kw...) = runtests(Returns(true), pkg; kw...)
230217
function runtests(shouldrun, pkg::Module; kw...)
231218
dir = pkgdir(pkg)
232219
isnothing(dir) && error("Could not find directory for `$pkg`")
@@ -263,7 +250,7 @@ function runtests(
263250
timeout_profile_wait >= 0 || throw(ArgumentError("`timeout_profile_wait` must be a non-negative number, got $(repr(timeout_profile_wait))"))
264251
# If we were given paths but none were valid, then nothing to run.
265252
!isempty(paths) && isempty(paths′) && return nothing
266-
shouldrun_combined(ti) = shouldrun(ti) && _shouldrun(name, ti.name) && _shouldrun(tags, ti.tags)
253+
ti_filter = TestItemFilter(shouldrun, tags, name)
267254
mkpath(RETESTITEMS_TEMP_FOLDER[]) # ensure our folder wasn't removed
268255
save_current_stdio()
269256
nworkers = max(0, nworkers)
@@ -274,10 +261,10 @@ function runtests(
274261
debuglvl = Int(debug)
275262
if debuglvl > 0
276263
LoggingExtras.withlevel(LoggingExtras.Debug; verbosity=debuglvl) do
277-
_runtests(shouldrun_combined, paths′, nworkers, nworker_threads, worker_init_expr, test_end_expr, timeout, retries, memory_threshold, verbose_results, debuglvl, report, logs, timeout_profile_wait, gc_between_testitems)
264+
_runtests(ti_filter, paths′, nworkers, nworker_threads, worker_init_expr, test_end_expr, timeout, retries, memory_threshold, verbose_results, debuglvl, report, logs, timeout_profile_wait, gc_between_testitems)
278265
end
279266
else
280-
return _runtests(shouldrun_combined, paths′, nworkers, nworker_threads, worker_init_expr, test_end_expr, timeout, retries, memory_threshold, verbose_results, debuglvl, report, logs, timeout_profile_wait, gc_between_testitems)
267+
return _runtests(ti_filter, paths′, nworkers, nworker_threads, worker_init_expr, test_end_expr, timeout, retries, memory_threshold, verbose_results, debuglvl, report, logs, timeout_profile_wait, gc_between_testitems)
281268
end
282269
end
283270

@@ -291,7 +278,7 @@ end
291278
# By tracking and reusing test environments, we can avoid this issue.
292279
const TEST_ENVS = Dict{String, String}()
293280

294-
function _runtests(shouldrun, paths, nworkers::Int, nworker_threads::String, worker_init_expr::Expr, test_end_expr::Expr, testitem_timeout::Int, retries::Int, memory_threshold::Real, verbose_results::Bool, debug::Int, report::Bool, logs::Symbol, timeout_profile_wait::Int, gc_between_testitems::Bool)
281+
function _runtests(ti_filter, paths, nworkers::Int, nworker_threads::String, worker_init_expr::Expr, test_end_expr::Expr, testitem_timeout::Int, retries::Int, memory_threshold::Real, verbose_results::Bool, debug::Int, report::Bool, logs::Symbol, timeout_profile_wait::Int, gc_between_testitems::Bool)
295282
# Don't recursively call `runtests` e.g. if we `include` a file which calls it.
296283
# So we ignore the `runtests(...)` call in `test/runtests.jl` when `runtests(...)`
297284
# was called from the command line.
@@ -311,7 +298,7 @@ function _runtests(shouldrun, paths, nworkers::Int, nworker_threads::String, wor
311298
if is_running_test_runtests_jl(proj_file)
312299
# Assume this is `Pkg.test`, so test env already active.
313300
@debugv 2 "Running in current environment `$(Base.active_project())`"
314-
return _runtests_in_current_env(shouldrun, paths, proj_file, nworkers, nworker_threads, worker_init_expr, test_end_expr, testitem_timeout, retries, memory_threshold, verbose_results, debug, report, logs, timeout_profile_wait, gc_between_testitems)
301+
return _runtests_in_current_env(ti_filter, paths, proj_file, nworkers, nworker_threads, worker_init_expr, test_end_expr, testitem_timeout, retries, memory_threshold, verbose_results, debug, report, logs, timeout_profile_wait, gc_between_testitems)
315302
else
316303
@debugv 1 "Activating test environment for `$proj_file`"
317304
orig_proj = Base.active_project()
@@ -324,7 +311,7 @@ function _runtests(shouldrun, paths, nworkers::Int, nworker_threads::String, wor
324311
testenv = TestEnv.activate()
325312
TEST_ENVS[proj_file] = testenv
326313
end
327-
_runtests_in_current_env(shouldrun, paths, proj_file, nworkers, nworker_threads, worker_init_expr, test_end_expr, testitem_timeout, retries, memory_threshold, verbose_results, debug, report, logs, timeout_profile_wait, gc_between_testitems)
314+
_runtests_in_current_env(ti_filter, paths, proj_file, nworkers, nworker_threads, worker_init_expr, test_end_expr, testitem_timeout, retries, memory_threshold, verbose_results, debug, report, logs, timeout_profile_wait, gc_between_testitems)
328315
finally
329316
Base.set_active_project(orig_proj)
330317
end
@@ -333,16 +320,16 @@ function _runtests(shouldrun, paths, nworkers::Int, nworker_threads::String, wor
333320
end
334321

335322
function _runtests_in_current_env(
336-
shouldrun, paths, projectfile::String, nworkers::Int, nworker_threads, worker_init_expr::Expr, test_end_expr::Expr,
323+
ti_filter, paths, projectfile::String, nworkers::Int, nworker_threads, worker_init_expr::Expr, test_end_expr::Expr,
337324
testitem_timeout::Int, retries::Int, memory_threshold::Real, verbose_results::Bool, debug::Int, report::Bool, logs::Symbol,
338-
timeout_profile_wait::Int, gc_between_testitems::Bool,
325+
timeout_profile_wait::Int, gc_between_testitems::Bool
339326
)
340327
start_time = time()
341328
proj_name = something(Pkg.Types.read_project(projectfile).name, "")
342329
@info "Scanning for test items in project `$proj_name` at paths: $(join(paths, ", "))"
343330
inc_time = time()
344331
@debugv 1 "Including tests in $paths"
345-
testitems, _ = include_testfiles!(proj_name, projectfile, paths, shouldrun, verbose_results, report)
332+
testitems, _ = include_testfiles!(proj_name, projectfile, paths, ti_filter, verbose_results, report)
346333
ntestitems = length(testitems.testitems)
347334
@debugv 1 "Done including tests in $paths"
348335
@info "Finished scanning for test items in $(round(time() - inc_time, digits=2)) seconds." *
@@ -660,49 +647,8 @@ function _is_subproject(dir, current_projectfile)
660647
return true
661648
end
662649

663-
# Error if we are trying to `include` a file with anything except an `@testitem` or
664-
# `@testsetup` call at the top-level.
665-
# Re-use `Base.include` to avoid duplicating subtle code-loading logic from `Base`.
666-
# Note:
667-
# For now this is just checks for `:macrocall` so we can support alternative macros that
668-
# expand to be an `@testitem`. We will likely _tighten_ this check in future.
669-
# i.e. We may in future throw an error for files that currently successfully get included.
670-
# i.e. Only `@testitem` and `@testsetup` calls are officially supported.
671-
checked_include(mod, filepath) = Base.include(check_retestitem_macrocall, mod, filepath)
672-
function check_retestitem_macrocall(expr)
673-
if Meta.isexpr(expr, :error)
674-
# If the expression failed to parse, most user-friendly to throw the ParseError,
675-
# rather than report an error about using only `@testitem` or `@testsetup`.
676-
Core.eval(Main, expr)
677-
end
678-
is_retestitem_macrocall(expr) || _throw_not_macrocall(expr)
679-
return expr
680-
end
681-
function is_retestitem_macrocall(expr::Expr)
682-
if expr.head == :macrocall
683-
# For now, we're not checking for `@testitem`/`@testsetup` only,
684-
# but we can still guard against the most common issue.
685-
name = expr.args[1]
686-
if name != Symbol("@testset") && name != Symbol("@test")
687-
return true
688-
end
689-
end
690-
return false
691-
end
692-
function _throw_not_macrocall(expr)
693-
# `Base.include` sets the `:SOURCE_PATH` before the `mapexpr`
694-
# (`check_retestitem_macrocall`) is first called
695-
file = get(task_local_storage(), :SOURCE_PATH, "unknown")
696-
msg = """
697-
Test files must only include `@testitem` and `@testsetup` calls.
698-
In $(repr(file)) got:
699-
$(Base.remove_linenums!(expr))
700-
"""
701-
error(msg)
702-
end
703-
704650
# for each directory, kick off a recursive test-finding task
705-
function include_testfiles!(project_name, projectfile, paths, shouldrun, verbose_results::Bool, report::Bool)
651+
function include_testfiles!(project_name, projectfile, paths, ti_filter::TestItemFilter, verbose_results::Bool, report::Bool)
706652
project_root = dirname(projectfile)
707653
subproject_root = nothing # don't recurse into directories with their own Project.toml.
708654
root_node = DirNode(project_name; report, verbose=verbose_results)
@@ -748,7 +694,7 @@ function include_testfiles!(project_name, projectfile, paths, shouldrun, verbose
748694
continue
749695
end
750696
fpath = relpath(filepath, project_root)
751-
file_node = FileNode(fpath, shouldrun; report, verbose=verbose_results)
697+
file_node = FileNode(fpath, ti_filter; report, verbose=verbose_results)
752698
testitem_names = Set{String}() # to enforce that names in the same file are unique
753699
push!(dir_node, file_node)
754700
@debugv 1 "Including test items from file `$filepath`"
@@ -757,7 +703,7 @@ function include_testfiles!(project_name, projectfile, paths, shouldrun, verbose
757703
task_local_storage(:__RE_TEST_ITEMS__, ($file_node, $testitem_names)) do
758704
task_local_storage(:__RE_TEST_PROJECT__, $(project_root)) do
759705
task_local_storage(:__RE_TEST_SETUPS__, $setup_channel) do
760-
checked_include(Main, $filepath)
706+
Base.include($ti_filter, Main, $filepath)
761707
end
762708
end
763709
end

src/filtering.jl

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Use a custom struct so accessing a non-existent field (i.e. trying to filer on anything
2+
# other than `name` or `tags`) gives a somewhat informative error.
3+
struct TestItemMetadata
4+
name::String
5+
tags::Vector{Symbol}
6+
end
7+
8+
struct TestItemFilter{
9+
F<:Function,
10+
T<:Union{Nothing,Symbol,AbstractVector{Symbol}},
11+
N<:Union{Nothing,AbstractString,Regex}
12+
} <: Function
13+
shouldrun::F
14+
tags::T
15+
name::N
16+
end
17+
18+
function (f::TestItemFilter)(ti::TestItemMetadata)
19+
return f.shouldrun(ti)::Bool && _shouldrun(f.tags, ti) && _shouldrun(f.name, ti)
20+
end
21+
22+
# This method is needed for filtering `TestItem`s that were created by
23+
# `___RAI_MACRO_NAME_DONT_USE` and so couldn't be filtered from the AST.
24+
# TODO: drop this method when we no longer support `___RAI_MACRO_NAME_DONT_USE`
25+
function (f::TestItemFilter)(ti::TestItem)
26+
return f.shouldrun(ti)::Bool && _shouldrun(f.tags, ti) && _shouldrun(f.name, ti)
27+
end
28+
29+
_shouldrun(name::AbstractString, ti) = name == ti.name
30+
_shouldrun(pattern::Regex, ti) = contains(ti.name, pattern)
31+
_shouldrun(tags::AbstractVector{Symbol}, ti) = issubset(tags, ti.tags)
32+
_shouldrun(tag::Symbol, ti) = tag in ti.tags
33+
_shouldrun(::Nothing, ti) = true
34+
35+
# Filter the AST before the code is evaluated.
36+
# Called when a `TestItemFilter` is passed as the first argument to `Base.include`.
37+
function (f::TestItemFilter)(expr::Expr)
38+
if Meta.isexpr(expr, :error)
39+
# If the expression failed to parse, most user-friendly to throw the ParseError,
40+
# rather than report an error about using only `@testitem` or `@testsetup`.
41+
Core.eval(Main, expr)
42+
end
43+
is_retestitem_macrocall(expr) || _throw_not_macrocall(expr)
44+
expr = filter_testitem(f, expr)
45+
return expr
46+
end
47+
48+
# Filter out `@testitem` calls based on the `name` and `tags` keyword passed to `runtests`.
49+
# Any other macro calls (e.g. `@testsetup`) are left as is.
50+
# If the `@testitem` call is not as expected, it is left as is so that it throws an error.
51+
#
52+
# Replacing the expression with `nothing` effectively removes it from the file.
53+
# `Base.include` will still call `Core.eval(M, nothing)` which has a tiny overhead,
54+
# but less than `Core.eval(M, :())`. We could instead replace `Base.include` with a
55+
# custom function that doesn't call `Core.eval(M, expr)` if `expr === nothing`, but
56+
# using `Base.include` means we benefit from improvements made upstream rather than
57+
# having to maintain our own version of that code.
58+
function filter_testitem(f, expr)
59+
@assert expr.head == :macrocall
60+
if expr.args[1] !== Symbol("@testitem")
61+
return expr
62+
end
63+
# `@testitem` have have at least: macro_name, line_number, name, body
64+
length(expr.args) < 4 && return expr
65+
name = try_get_name(expr)
66+
name === nothing && return expr
67+
tags = try_get_tags(expr)
68+
tags === nothing && return expr
69+
ti = TestItemMetadata(name, tags)
70+
return f(ti) ? expr : nothing
71+
end
72+
73+
# Extract the name from a `@testitem`, return `nothing` if name is not of the expected type.
74+
function try_get_name(expr::Expr)
75+
@assert expr.head == :macrocall && expr.args[1] == Symbol("@testitem")
76+
name = expr.args[3]
77+
return name isa String ? name : nothing
78+
end
79+
80+
# Extract the tags from a `@testitem`, return `nothing` if tags is not of the expected type.
81+
# The absence of a `tags` keyword is the same as setting `tags=[]`
82+
function try_get_tags(expr::Expr)
83+
@assert expr.head == :macrocall && expr.args[1] == Symbol("@testitem")
84+
tags = Symbol[]
85+
for args in expr.args[4:end]
86+
if args isa Expr && args.head == :(=) && args.args[1] == :tags
87+
tags_arg = args.args[2]
88+
if tags_arg isa Expr && tags_arg.head == :vect
89+
for tag in tags_arg.args
90+
if tag isa QuoteNode && tag.value isa Symbol
91+
push!(tags, tag.value)
92+
else
93+
return nothing
94+
end
95+
end
96+
else
97+
return nothing
98+
end
99+
end
100+
end
101+
return tags
102+
end
103+
104+
# Macro used by RAI (corporate sponsor of this package)
105+
# TODO: drop support for this when RAI codebase is fully switched to ReTestItems.jl
106+
const ___RAI_MACRO_NAME_DONT_USE = Symbol("@test_rel")
107+
108+
# Check if the expression is a macrocall as expected.
109+
# NOTE: Only `@testitem` and `@testsetup` calls are officially supported.
110+
function is_retestitem_macrocall(expr::Expr)
111+
if expr.head == :macrocall
112+
name = expr.args[1]
113+
return name === Symbol("@testitem") || name === Symbol("@testsetup") || name === ___RAI_MACRO_NAME_DONT_USE
114+
else
115+
return false
116+
end
117+
end
118+
119+
function _throw_not_macrocall(expr)
120+
# `Base.include` sets the `:SOURCE_PATH` before the `mapexpr` is first called
121+
file = get(task_local_storage(), :SOURCE_PATH, "unknown")
122+
msg = """
123+
Test files must only include `@testitem` and `@testsetup` calls.
124+
In $(repr(file)) got:
125+
$(Base.remove_linenums!(expr))
126+
"""
127+
error(msg)
128+
end

src/macros.jl

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,10 @@ macro testitem(nm, exs...)
261261
push!(kw_seen, kw)
262262
if kw == :tags
263263
tags = ex.args[2]
264-
@assert tags isa Expr "`tags` keyword must be passed a collection of `Symbol`s"
264+
@assert(
265+
tags isa Expr && all(t -> t isa QuoteNode && t.value isa Symbol, tags.args),
266+
"`tags` keyword must be passed a collection of `Symbol`s"
267+
)
265268
elseif kw == :default_imports
266269
default_imports = ex.args[2]
267270
@assert default_imports isa Bool "`default_imports` keyword must be passed a `Bool`"
@@ -298,6 +301,7 @@ macro testitem(nm, exs...)
298301
end
299302
end
300303
end
304+
@assert !_run || nm isa String "`@testitem` expects a `String` literal name as the first argument"
301305
if isempty(exs) || !(exs[end] isa Expr && exs[end].head == :block)
302306
error("expected `@testitem` to have a body")
303307
end

src/testcontext.jl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ end
3434

3535
# FilteredVector applies a filtering function `f` to items
3636
# when you try to `push!` and only puts if `f` returns true.
37+
# TODO: drop this when all filtering is done at the AST level
38+
# i.e. when we drop support for _RAI_MACRO_NAME
3739
struct FilteredVector{T} <: AbstractVector{T}
3840
f::Any
3941
vec::Vector{T}
@@ -50,7 +52,7 @@ struct FileNode
5052
testitems::FilteredVector{TestItem} # sorted by line number within file
5153
end
5254

53-
function FileNode(path, f=default_shouldrun; report::Bool=false, verbose::Bool=false)
55+
function FileNode(path, f=Returns(true); report::Bool=false, verbose::Bool=false)
5456
junit = report ? JUnitTestSuite(path) : nothing
5557
return FileNode(path, DefaultTestSet(path; verbose), junit, FilteredVector(f, TestItem[]))
5658
end

0 commit comments

Comments
 (0)