Every tarpolyglot constructor
takes its code through script arguments: the foreign
script, and, where the language has them, an R
pre_script and post_script. Each of those
accepts the same three forms, and this vignette is about choosing
between them.
The choice is not cosmetic. It decides whether editing your
code re-runs the step, which is the difference between a
pipeline that rebuilds when you change a script and one that quietly
serves you a stale result. Examples below use
tar_target_py(), but everything applies unchanged to
tar_target_jl(), tar_target_rs(),
tar_target_cpp(), and the _raw() forms.
| Form | Where the code lives | Tracked by targets? |
Editing the code re-runs the step? |
|---|---|---|---|
"py/step.py" |
a file on disk | no | no |
tar_target_path("step_py") |
a file on disk | yes | yes |
tar_code(...) |
inside _targets.R |
yes (hashed in the command) | yes |
The simplest form: give the path as an ordinary string.
library(targets)
library(tarpolyglot)
list(
tar_target(prepared_x, c(1, 2, 3, 4)),
tar_target_py(
name = py_sum,
script = "py/step.py",
inputs = c(x = "prepared_x"),
pre_script = "R/pre.R",
retrieve = "result"
)
)The file is read when the step runs, but targets never
looks at its contents, so the path is untracked.
Editing py/step.py does not invalidate py_sum:
the target stays up to date and keeps returning its old value until some
other tracked dependency (here, prepared_x)
changes.
That is occasionally what you want, and it is the least ceremony, so it is a reasonable default for a script that has settled down. It is a poor default while you are still writing the script, because the pipeline will not notice your edits.
tar_target_path() referenceTo make edits count, track the file as a real target and point at it by target name with `tarpolyglot::tar_target_path()`.
list(
tar_target(prepared_x, c(1, 2, 3, 4)),
# The script becomes a file target: targets watches its contents.
tar_target(step_py, "py/step.py", format = "file"),
tar_target_py(
name = py_sum,
script = tar_target_path("step_py"),
inputs = c(x = "prepared_x"),
retrieve = "result"
)
)Two things are happening. The
tar_target(step_py, "py/step.py", format = "file") target
hashes the file, so targets re-runs it whenever the file
changes. Then tar_target_path("step_py") wires that target
in as a dependency of py_sum, rather than passing the
literal string "step_py" as a path.
Now editing py/step.py invalidates step_py,
which invalidates py_sum, and the step re-runs. This is the
recommended form for a reproducible pipeline, and the one to reach for
while a script is under active development.
tar_target_path() takes the target
name, not a path. This is the common mistake:
tar_code()Sometimes a step is three lines long and a separate file is more
bookkeeping than it is worth. tar_code() lets the code live
directly in _targets.R.
list(
tar_target(prepared_x, c(1, 2, 3, 4)),
tar_target_py(
name = py_sum,
inputs = c(x = "prepared_x"),
pre_script = tar_code({ to_py <- list(x = x) }),
script = tar_code("result = sum(x)"),
post_script = tar_code({ py_get("result") })
)
)Inline code is embedded in the target’s command, so
targets hashes it like any other part of the command:
editing the code invalidates the target, with no
format = "file" target needed.
Note the two flavours, which are not interchangeable:
pre_script/post_script.{ ... } block carries inline
R and is valid only for
pre_script/post_script. Passing one as the
foreign script is an error, since a { } block
is R code, not Python.# Error: an R block cannot be the Python script.
tar_target_py(name = bad, script = tar_code({ result <- 1 }))Multi-line strings are dedented, so you can indent inline Python to
line up with the surrounding _targets.R and its own block
indentation still comes out valid:
The three forms are per argument, so you can mix them freely. A
common combination is a tracked script with a small inline
post_script:
tar_target_path(). You want every edit to re-run the step;
anything else will waste your time on stale results.tar_code().
No file to manage, and edits still invalidate correctly.tar_target_path() the
moment you start editing again.When in doubt, prefer tar_target_path() or
tar_code(). Both make targets do what people
expect: change the code, re-run the step.
The script is often not the only file a step needs. A
Python step may import a helper module, a Julia step may
include() a file of shared definitions. Tracking the script
itself does nothing for those: targets has no idea the
helper exists, so editing it leaves the step up to date and hands you a
stale value. This is the same failure as an untracked script, and it is
quieter, because the script you are tracking has not
changed.
Here the helper is invisible to the pipeline:
# py/helper.py -> def scale(v): return [2 * i for i in v]
# py/step.py -> import sys; sys.path.insert(0, "py")
# import helper
# result = sum(helper.scale(x))
list(
tar_target(prepared_x, c(1, 2, 3, 4)),
tar_target_py(
name = py_total,
script = "py/step.py",
inputs = c(x = "prepared_x"),
pre_script = tar_code({ to_py <- list(x = x) }),
retrieve = "result"
)
)Change helper.py to scale by 3 and py_total
stays up to date, still reporting the result computed with the old
factor.
inputsinputs maps a name you will see inside the step to the
name of an upstream target. Point it at a format = "file"
target and you get both halves at once: the helper becomes a real
dependency, so editing it re-runs the step, and its path is bound in the
step environment, so the script can load it.
list(
tar_target(prepared_x, c(1, 2, 3, 4)),
# The helper becomes a file target: targets hashes its contents.
tar_target(helper_file, "py/helper.py", format = "file"),
tar_target_py(
name = py_total,
script = "py/step.py",
inputs = c(x = "prepared_x", helper_path = "helper_file"),
pre_script = tar_code({ to_py <- list(x = x, helper_path = helper_path) }),
retrieve = "result"
)
)with py/step.py loading the helper from the path it was
handed, rather than a hard-coded one:
import os, sys
sys.path.insert(0, os.path.dirname(helper_path))
import helper
result = sum(helper.scale(x))Now editing py/helper.py re-hashes
helper_file, which puts py_total out of date,
and the step re-runs against the new helper.
Like tar_target_path(), inputs takes
target names, not paths. The same mistake applies:
# Wrong: looks for a target called "py/helper.py".
inputs = c(helper_path = "py/helper.py")
# Right: name the file target you declared above.
inputs = c(helper_path = "helper_file")Several helpers are simply several entries, each with its own file target:
The shape is identical, with to_jl in place of
to_py. One difference matters: Julia resolves a relative
path given to include() against the directory of the file
doing the including, not the working directory, so a relative helper
path from a file target will not be found. Make it absolute in the
pre-script.
list(
tar_target(prepared_x, c(1, 2, 3, 4)),
tar_target(helper_file, "jl/helper.jl", format = "file"),
tar_target_jl(
name = jl_total,
script = "jl/step.jl",
inputs = c(x = "prepared_x", helper_path = "helper_file"),
pre_script = tar_code({
to_jl <- list(x = x, helper_path = normalizePath(helper_path, winslash = "/"))
}),
retrieve = "result"
)
)with jl/step.jl:
For Python and Julia the helper is loaded at run time, from
a path the step is handed, which is exactly what inputs
provides. C++ and Rust instead need their helper at compile
time, and tarpolyglot reads your script and passes its
text to Rcpp::sourceCpp(code = ...) and
rextendr::rust_source(code = ...). The compiler therefore
runs in a temporary build directory rather than beside your source file,
so a neighbouring helper is not on its search path and a relative
#include "helper.h" or include!("helper.rs")
does not resolve.
The tracking half still works: listing the helper in
inputs makes it a dependency, so editing it re-runs the
step and recompiles. It is only the loading half that a
relative path cannot do. For C++ an absolute #include does
resolve, so a helper header is usable if you are willing to hard-code an
absolute path, or build the source with glue() (see the
next section) so the path is filled in when the DAG is built.
In practice, for a compiled step the simplest answer is usually to
keep the code in one file, and to reach for depends (C++)
or dependencies (Rust) when what you want is a real library
rather than a local helper.
glue()Inline code is often assembled with glue::glue(). That
runs when _targets.R is sourced, i.e. when the
pipeline’s DAG is built, not when the step runs. It can therefore use
ordinary R objects in scope at that moment, but it cannot see the
values of upstream targets: those only exist while the pipeline
is running.
# known when _targets.R is sourced by being put on top of the _targets.R file
# or in a R file sourced by targets::tar_source() (see the targets documentation)
# when targets::tar_make() is called.
measurement <- "flipper_length_mm"
tar_target_py(
name = py_mean,
inputs = c(df = "penguins"),
pre_script = tar_code({ to_py <- list(df = df) }),
script = tar_code(glue::glue("result = float(df['{measurement}'].mean())")),
retrieve = "result"
)Use inputs for anything that depends on upstream target
values, and glue() only for things already known when the
DAG is built.
?tar_code and ?tar_target_path for the
argument reference, vignette("python") /
vignette("julia") / vignette("rust") /
vignette("cpp") for the per-language details, and
vignette("get_started") for how the pieces fit
together.