Skip to content

Commit

Permalink
Add support for explicit export specifications and legacy modules (kl…
Browse files Browse the repository at this point in the history
…mr#227)

* Add test case for legacy modules feature
* Add test case for explicit exports
* Add `export` function
* Add documentation of `export` functionality
  • Loading branch information
klmr authored Aug 1, 2021
1 parent 519cc3f commit 4206cc8
Show file tree
Hide file tree
Showing 8 changed files with 138 additions and 1 deletion.
12 changes: 12 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# box (development version)

## Breaking changes

* Modules without any `@export` declarations now export all visible names (=
names not starting with a dot); to obtain the previous semantics of a module
without exports, add a call to `box::export()` to the module source code.

## General

* Enhancement: support legacy modules (aka. R scripts) better by exporting all
visible names (#207)
* Enhancement: permit specifying exports via a function call instead of via
`@export` declarations (#227)
* Fix: make interactive HTML module help work on Windows (#223)
* Fix: prevent segfault in R ≤ 3.6.1 caused by missing declaration of internal R
symbol (#213)
Expand Down
54 changes: 54 additions & 0 deletions R/export.r
Original file line number Diff line number Diff line change
@@ -1,3 +1,57 @@
#' Explicitly declare module exports
#'
#' \code{box::export} can be used as an alternative to the \code{@export}
#' tag comment to declare a module’s exports.
#'
#' @param ... zero or more unquoted names that should be exported from the
#' module.
#' @return \code{box::export} has no return value. It is called for its
#' side-effect.
#'
#' @details
#' \code{box::export} can be called inside a module to specify the module’s
#' exports. If a module contains a call to \code{box::export}, this call
#' overrides any declarations made via the \code{@export} tag comment. When a
#' module contains multiple calls to \code{box::export}, the union of all thus
#' defined names is exported.
#'
#' A module can also contain an argument-less call to \code{box::export}. This
#' ensures that the module does not export any names. Otherwise, a module that
#' defines names but does not mark them as exported would be treated as a
#' \emph{legacy module}, and all default-visible names would be exported from
#' it. Default-visible names are names not starting with a dot (\code{.}).
#'
#' @note The preferred way of declaring exports is via the \code{@export} tag
#' comment. The main purpose of \code{box::export} is to prevent exports, by
#' being called without arguments.
#'
#' @seealso
#' \code{\link{use}} for information on declaring exports via \code{@export}.
#' @export
export = function (...) {
mod_ns = mod_topenv(parent.frame())
if (! is_namespace(mod_ns)) {
stop(sprintf('%s can only be called from inside a module', dQuote('export')))
}

names = as.list(match.call())[-1L]
for (name in names) {
if (! is.name(name)) {
stop(sprintf(
'%s requires a list of unquoted names; %s is not a name',
dQuote('export()'), dQuote(deparse(name))
))
}
}

namespace_info(mod_ns, 'exports') = union(
namespace_info(mod_ns, 'exports'),
as.character(unlist(names))
)
namespace_info(mod_ns, 'legacy') = FALSE
invisible()
}

#' Find exported names in parsed module source
#'
#' @param info The module info.
Expand Down
30 changes: 29 additions & 1 deletion R/use.r
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,26 @@
#' argument matching, in contrast with the behavior of the \code{$} operator in
#' base R, which matches partial names.
#'
#' @section Export specification:
#'
#' Names defined in modules can be marked as \emph{exported} by prefixing them
#' with an \code{@export} tag comment; that is, the name needs to be immediately
#' prefixed by a comment that reads, verbatim, \code{#' @export}. That line may
#' optionally be part of a \pkg{roxygen2} documentation for that name.
#'
#' Alternatively, exports may be specified via the
#' \code{\link[=export]{box::export}} function, but using declarative
#' \code{@export} tags is generally preferred.
#'
#' A module which has not declared any exports is treated as a \emph{legacy
#' module} and exports \emph{all} default-visible names (that is, all names that
#' do not start with a dot (\code{.}). This usage is present only for backwards
#' compatibility with plain R scripts, and its usage is \emph{not recommended}
#' when writing new modules.
#'
#' To define a module that exports no names, call \code{box::export()} without
#' arguments. This prevents the module from being treated like a legacy module.
#'
#' @section Search path:
#' Modules are searched in the module search path, given by
#' \code{getOption('box.path')}. This is a character vector of paths to search,
Expand Down Expand Up @@ -161,6 +181,7 @@
#' \code{\link{unload}} and \code{\link{reload}} aid during module development
#' by performing dynamic unloading and reloading of modules in a running R
#' session.
#' \code{\link{export}} can be used inside a module to declare module exports.
#' @export
use = function (...) {
caller = parent.frame()
Expand Down Expand Up @@ -314,7 +335,14 @@ load_from_source = function (info, mod_ns) {
# http://stackoverflow.com/q/5031630/1968 for a discussion of this.
exprs = parse(info$source_path, keep.source = TRUE, encoding = 'UTF-8')
eval(exprs, mod_ns)
namespace_info(mod_ns, 'exports') = parse_export_specs(info, exprs, mod_ns)

if (is.null(namespace_info(mod_ns, 'exports'))) {
exports = parse_export_specs(info, exprs, mod_ns)
is_legacy = length(exports) == 0L
namespace_info(mod_ns, 'legacy') = is_legacy
namespace_info(mod_ns, 'exports') = if (is_legacy) ls(mod_ns) else exports
}

make_S3_methods_known(mod_ns)
}

Expand Down
1 change: 1 addition & 0 deletions pkgdown/_pkgdown.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ reference:
- title: Writing modules
desc: Infrastructure and utility functions for use inside modules
contents:
- export
- file
- name
- register_S3_method
Expand Down
9 changes: 9 additions & 0 deletions tests/testthat/mod/explicit_exports.r
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
box::use(a = ./a[...])

double = function (x) c(x, x)

hidden = 'hidden'

# module, local name, imported name
# └──────┐ └──┐ ┌─┘
box::export(a, double, modname)
3 changes: 3 additions & 0 deletions tests/testthat/mod/legacy/__init__.r
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
a = 1
b = 'foo'
.c = 'hidden'
4 changes: 4 additions & 0 deletions tests/testthat/mod/no_exports.r
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ box::use(./a[...])
double = function (x) c(x, x)

indirect_counter = function () get_counter()

.hidden = 'hidden'

box::export()
26 changes: 26 additions & 0 deletions tests/testthat/test-basic.r
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,29 @@ test_that('r/core can be imported', {
test_that('modules can be imported and exported by different local names', {
expect_error(box::use(mod/issue211), NA)
})

test_that('legacy modules export all names', {
is_legacy = function (mod) {
box:::namespace_info(attr(mod, 'namespace'), 'legacy', default = FALSE)
}

box::use(
mod/a,
mod/legacy,
mod/no_exports
)

expect_false(is_legacy(a))
expect_true(is_legacy(legacy))
expect_false(is_legacy(no_exports))

expect_setequal(ls(legacy), c('a', 'b'))
expect_setequal(ls(no_exports), character())
})

test_that('modules can specify explicit exports', {
box::use(ex = mod/explicit_exports)
expect_setequal(ls(ex), c('a', 'double', 'modname'))
expect_identical(ex$double, attr(ex, 'namespace')$double)
expect_equal(ex$modname, ex$a$modname)
})

0 comments on commit 4206cc8

Please sign in to comment.