Adds a backend: the engine that creates Conda environments and runs
commands in them. condathis has one built-in backend, "micromamba".
Another package can provide a backend and register it from its
.onLoad() (see Registering from a backend package). The backend's
name is then a valid method value of every condathis function that
takes method, and method = "auto" can select it.
Usage
register_backend(name, backend, call = rlang::caller_env())Arguments
- name
Character string, the backend name that users pass as
method. It must start with a letter, contain only letters, digits,.,_and-, and not end with.. It must not be a Windows device name, such as"nul","con"or"com1", with or without an extension. Reserved, in any case:"auto","native"and"micromamba", the names of the entries of the condathis data directory ("envs","pkgs","micromamba","condabin","Scripts","condathis-micromamba.yaml","cache","home","channels"and"locks"), and"tmp","bin","lib","etc"and"conda-meta".register_backend()also refuses a name when the condathis data directory has an entry of that name that is not a directory. It also refuses a name that differs only in case from a registered backend name or from the backend name of a package thatcondathisknows, such as"rattler". On macOS and Windows, both would have the same root. Registering a name again replaces its backend.- backend
The backend object: a list of the backend functions, with its own class. See The backend object.
- call
The environment reported as the caller in errors, for calls from another package's
.onLoad(), as in rlang.
Value
name, invisibly. Aborts with these classes:
condathis_backend_invalid_name: a name that breaks the rules above.condathis_backend_reserved_name(alsocondathis_backend_invalid_name): a reserved name.condathis_backend_root_conflict(alsocondathis_backend_invalid_name): a name whose entry in the condathis data directory is not a directory.condathis_backend_name_conflict(alsocondathis_backend_invalid_name): a name that differs only in case from another backend name.condathis_backend_class_conflict: another registered name uses the same first class.condathis_backend_contract_violation: a backend object that breaks the contract. The error names the missing functions or arguments.
Every error also has the class condathis_error, and the name errors
have condathis_invalid_arg.
Details
backend is a list of backend functions, with its own class: the three
required functions described below, and any of the optional ones.
condathis supplies a default for every optional function that the list
leaves out. register_backend() checks the list, then registers each
function as an S3 method of the condathis generic of the same name,
from inside the condathis namespace. The backend package needs no S3
registrations in its NAMESPACE.
Lifecycle
This extension API is experimental. condathis 0.2.0 defines the
contract, designed together with a second backend package
(rattlerthis). The API may still change in breaking ways between
0.x releases. State the condathis version that you developed
against, for example as condathis (>= 0.2.0) in Imports.
The backend object
Build the backend as
structure(list(<the functions>), class = c("condathis_backend_<name>", "condathis_backend")).
S3 dispatch keys on the first class, so it must be unique to the
backend. It must not be "condathis_backend", "default", "list" or
"data.frame": methods for those classes would also catch objects that
are not this backend. The element names are the names of the functions
below. The object must have backend_create_env, backend_install and
backend_resolve_run. Each other function is optional: an element that
is missing or NULL gets the default of condathis. Each function must
accept every argument of its signature by name, or have a ...
argument. register_backend() checks the class and the three required
functions. It checks that each other element of the contract is a
function or NULL. It also checks the rule of the next paragraph and
the arguments of the functions that are present. Extra elements are
ignored.
An object that has its own backend_get_install_dir() must also have
its own backend_get_env_dir(), backend_list_envs() and
backend_env_exists(). An object that has its own
backend_get_env_dir() must also have its own backend_list_envs() and
backend_env_exists(). Their defaults look for environments in
<backend_root_dir(name)>/envs, not where the backend keeps them.
Roots
Each backend has a root, its data directory, which get_install_dir()
reports. The root of the "micromamba" backend is the condathis data
directory, tools::R_user_dir("condathis", "data"). The root of every
other backend is the directory of its name inside it,
backend_root_dir(): for example <condathis data directory>/rattler.
So the root of a backend can lie inside the root of micromamba. The
names of the entries of the condathis data directory are reserved (see
name). Each backend has its own environment directory, <root>/envs,
and its own package caches, and never uses those of another backend.
condathis owns <root>/conda-pkgs in the root of every backend (see
Child processes), so a backend must not use that name.
with_sandbox_dir() moves the condathis data directory, and with it
every root that backend_root_dir() gives.
What condathis passes
condathis passes backend first and every other argument by name. It
checks every value before it calls a backend function. verbose is
always one of the five strings, so the defaults of the signatures below
are never used. The values are:
backend: the registered backend object.env_name: a valid environment name, as described for theenv_nameargument ofcreate_env(). It is a single directory name, never a path.packages: a character vector of package match specs, such as"python>=3.12", withoutNAor"".backend_create_env()getsNULLwhen the request has no packages: an empty environment, or one built fromenv_file.env_file:NULL, or the path of an existing environment file. When it is set,packagesisNULL.channels: a non-empty character vector of channel names or URLs, withoutNAor"", in priority order.additional_channels:NULL, or a character vector likechannels. These channels come afterchannels.channel_priority:"strict","flexible"or"disabled".platform:NULL, or one known conda platform (subdir) name, such as"linux-64"or"osx-arm64"(seeplatformincreate_env()). It can still be a platform that the backend does not support.NULLmeans the native platform of the backend.overwrite:TRUEorFALSE.verbose: one of"output"(show the backend's output),"silent"(show nothing),"cmd"(show the command line),"spinner"(show only progress) or"full"(show everything, also the backend's own log). It is never a logical or a vector. The probes and listings thatcondathisuses to choose a backend get"silent".condathisprints its own messages, such as "Environment x successfully created.", so the backend must not print them again.cmd: a single, non-empty string.args: a character vector.base_env: a named character vector, the environment of the command before activation (seebackend_resolve_run()).env_dir: the absolute path of an existing directory with aconda-metadirectory, in its real form (seebackend_resolve_run_in_dir()).timeout: a positive number of seconds. Forbackend_resolve_run()andbackend_resolve_run_in_dir(), it can also beInf(no limit).
create_env(), install_packages() and remove_env() hold the lock of
the environment name (see Other R sessions in create_env()). They
hold it while they call backend_create_env(), backend_install() or
backend_remove_env(), and during the existence checks before them. So
no other R session that uses condathis changes the environment
meanwhile, with any backend. They also hold the lock of the package
caches, shared. clean_cache() holds it exclusive while it calls
backend_clean_cache(). So no create or install of another R session
runs while caches are deleted. A backend needs no lock of its own
against those sessions.
Required functions
backend_create_env(backend, packages = NULL, env_file = NULL, env_name = "condathis-env", channels = "conda-forge", channel_priority = c("strict", "flexible", "disabled"), additional_channels = NULL, platform = NULL, overwrite = FALSE, verbose = c("output", "silent", "cmd", "spinner", "full"))creates the environment in the directory thatbackend_get_env_dir()returns.condathiscalls it only when that directory does not exist. That is the case for a new environment. It is also the case withoverwrite = TRUE, aftercondathismoved the existing environment aside or deleted a directory that was not an environment (one left by a failed create). When the call fails,condathisdeletes what it left and moves the old environment back. A call fails also when it returns a non-zerostatusor an invalidlink_scripts_skipped(see Results). So a misspelled spec withoverwrite = TRUEdoes not cost the user the environment.overwritetells the backend which of the two cases applies. Withenv_file, build the environment from that file. Withplatform, build it for that platform. The platform can be one thatcondathischose because the request does not solve for the native platform of the backend (seebackend_can_solve()). After a successful call,condathiswrites<env_dir>/.condathis/backend.json. It uses this marker as a cross-check, and the marker records the platform when one was given or chosen.condathismoves aside or deletes only the directory<backend_get_install_dir()>/envs/<env_name>. For a backend whosebackend_get_env_dir()returns another directory,create_env()cannot replace an existing environment, or delete a directory left by a failed create. It aborts withcondathis_create_env_failed, whose parent has the classcondathis_unsafe_env_dir_moveorcondathis_unsafe_env_dir_delete. It keeps the environment that it replaces in<backend_get_install_dir()>/envs/#trashuntil the backend succeeded. No environment can have that name, sobackend_list_envs()must leave that directory out.backend_install(backend, packages, env_name = "condathis-env", channels = "conda-forge", channel_priority = c("strict", "flexible", "disabled"), additional_channels = NULL, platform = NULL, verbose = c("output", "silent", "cmd", "spinner", "full"))installspackagesinto the existing environmentenv_name, solving forplatform.condathispasses the platform recorded for the environment: the one in its marker or its.mambarc. On Apple Silicon and on Windows,condathiscan choose an emulated platform (seebackend_can_solve()). There it also reads the platform of the package records of an environment without a recorded one. For an environment without packages of a platform, it passes the platform that it would choose for a new environment. Otherwise,platformisNULL. It must not create a missing environment.backend_resolve_run(backend, cmd, args = character(0L), env_name = "condathis-env", verbose = "silent", base_env = NULL, timeout = Inf)describes, without runningcmd, how to runcmdwithargsin the activated environment.base_envis the environment that the command gets without activation: the session's environment with the changes of Child processes. Compute the activation against it, not against the environment of the R session.condathisalways passes it.timeoutis the number of seconds the function may take: what is left of thetimeoutofrun(),run_bin()orrun_pipeline(), orInf. If the function runs code to compute the activation, such as theactivate.dscripts of the environment, that code can hang. The function should stop it when the time runs out, and signal an error. When the time ran out,condathisreports a timeout for the call (timeout = TRUE, status-9), whatever the function returned or signaled. It does not start the command.timeoutis the one argument a function may leave out. Thencondathisdoes not pass it, and can report the timeout only once the function returned. The function returnslist(command, args, env, dir):command: a single string, the program to start: an absolute path, or a bare name.condathisstarts a bare name the same way for every backend. On Linux and macOS, it is looked up on thePATHof the command, afterenvis applied. On Windows,run()andrun_pipeline()look it up in the binary directories of the environment, with the extensions ofPATHEXT. Otherwise, they leave it toprocessx, which searches the working directory and thePATHof the R session for<name>.comand<name>.exe.run_bin()searches the binary directories of the environment and then thePATHof the command. A bare name that is found nowhere fails to start.args: a character vector, orNULL. It replaces the user's arguments, so it must contain them.env:NULL, or a named character vector (not a list) of activation variables. Each entry replaces the variable of the same name in the environment of the command,PATHincluded. AnNAentry removes the variable, for a variable that the activation unsets.run_bin()also prepends the environment's binary directories toPATH.dir:NULLto keep R's working directory, or the path of an existing directory to run the command in.
condathiscalls it only with activation on (run(), andrun_bin()andrun_pipeline()withactivate = TRUE). It calls it only for an environment that this backend reported as existing. It never calls it for anenv_path()environment (seebackend_resolve_run_in_dir()). A value that breaks these rules aborts withcondathis_backend_resolve_run_invalid.
Optional functions and their defaults
The defaults treat the environments of a backend as conda prefixes in
<backend_root_dir(name)>/envs. A directory there with
conda-meta/history is an environment, and the conda-meta/*.json
files are its package records.
backend_get_install_dir(backend)returns the backend's root: the absolute path of an existing directory, in its real form (fs::path_real()). Paths then compare equal where a temporary directory is a symbolic link (macOS). Create the directory when it is missing. Default:backend_root_dir(), created when missing.backend_get_env_dir(backend, env_name = "condathis-env")returns the absolute path of the environment directory (its prefix): the directory thatbackend_create_env()creates. It returns the path also for an environment that does not exist yet, and it must not create anything. Default:<backend_root_dir(name)>/envs/<env_name>.backend_list_envs(backend, verbose = "silent")returns the names of this backend's environments: a character vector withoutNAor duplicates. Names of the vector are dropped. It returnscharacter(0)when there are none, also when the backend's root does not exist yet. It is read only: it must not create anything, and it should not install or download anything. Default: the directories in<root>/envsthat haveconda-meta/history, without symbolic links and without directories whose real path is elsewhere, asmicromamba env listreports them.backend_env_exists(backend, env_name, verbose = "silent")returnsTRUEorFALSE: whetherenv_nameis an environment of this backend. It must agree withbackend_list_envs().condathiscalls it for every registered backend on every call that names an environment, whatevermethodis, to find the backend that owns the environment. So it must be cheap and must not create anything. Avoid processes and downloads. Report only the environments in this backend's own root: when two backends report the same name,condathisaborts withcondathis_backend_ambiguous_envunlessmethodnames one of them. Default:FALSEwhen<root>/envs/<env_name>is not a directory, else whetherbackend_list_envs()listsenv_name(a file system that ignores case opens the directory for every spelling).backend_list_packages(backend, env_name = "condathis-env", verbose = "silent")returns a data frame (a tibble is fine) with one row per installed package and at least these columns:name,version,build_string,channelandplatform(character), andbuild_number(integer, or a double with whole numbers).platformis the subdir of the package, such as"linux-64"or"noarch". Extra columns are kept. An empty environment gives zero rows with the same columns.create_env()reads these columns to decide whether an existing environment already has the requested packages and was built for the requested platform. Default: the package records, with the columns ofmicromamba list --json, and the PyPI packages that pip or uv installed into the environment's python, asmicromamba listlists them (channelpypi, and asplatforma tag such aslinux-x86_64). Seelist_packages().backend_remove_env(backend, env_name = "condathis-env", verbose = c("output", "silent", "cmd", "spinner", "full"))removes the environment and its directory. Default: deletesconda-meta/historyfirst, so the environment leaves every listing at once. Then it moves the directory into<root>/envs/#trashand deletes it there. When a program uses one of its files (Windows), the directory cannot be moved. Then it deletes the files one by one, and moves each file in use into#trash, where a later call deletes it. A symbolic link is deleted, never followed. It does not run pre-unlink scripts, and reports the packages that have one (see Results). Likecreate_env(), it deletes only the directory<backend_get_install_dir()>/envs/<env_name>. A backend whosebackend_get_env_dir()returns another directory needs its ownbackend_remove_env(). Without it,remove_env()aborts withcondathis_remove_env_failed, whose parent has the classcondathis_unsafe_env_dir_delete.backend_available(backend)returnsTRUEorFALSE: whether the backend can create environments on this system now. It must be cheap. It only decides which backendmethod = "auto"picks for a new environment.condathisstill lists, finds and uses the existing environments of an unavailable backend. Default:TRUE.backend_clean_cache(backend, verbose = c("output", "silent", "cmd", "spinner", "full"))deletes the package caches and the metadata caches of the backend, and nothing else. It returnsNULLwhen there was nothing to clean, or a result list (see Results).clean_cache()calls it for every registered backend, also after the function of another backend failed, and then deletes<root>/conda-pkgsitself. Default:NULL.backend_quiesce(backend, timeout = 30)waits until no work of the backend runs in the R process any more, at mosttimeoutseconds, and returnsTRUE(invisibly), orFALSEwith a warning when the time ran out.condathiscalls it beforewith_sandbox_dir()andclean_cache()delete directories. An error from it, or a value other thanTRUEorFALSE, becomes a warning of classcondathis_backend_quiesce_failed, andcondathisgoes on. A backend that only starts processes has nothing to wait for. Default:TRUE.backend_native_platform(backend)returns the platform (subdir) that the backend solves for when it gets noplatform, such as"osx-arm64", orNAwhen it cannot tell. Default:NA.backend_can_solve(backend, packages = NULL, env_file = NULL, channels = "conda-forge", channel_priority = c("strict", "flexible", "disabled"), additional_channels = NULL, platform)returnsTRUEwhen the request solves forplatform,FALSEwhen it does not, andNAwhen it cannot tell, for example without network access or for a missing channel. It must not create an environment.condathisasks it when it chooses the platform of a new environment. A request that does not solve for the native platform uses an emulated one when it solves there:"osx-64"on Apple Silicon with Rosetta 2, and"win-64"on Windows on ARM. An error counts asNA. Default:NA, socondathisnever chooses an emulated platform.backend_resolve_run_in_dir(backend, cmd, args = character(0L), env_dir, verbose = "silent", base_env = NULL, timeout = Inf)describes how to runcmdwithargsin the activated environment whose directory (its prefix) isenv_dir. This is an environment given byenv_path(), whichcondathisdid not create and which may belong to no backend, such as one made by conda or mamba elsewhere. It returnslist(command, args, env, dir)with the rules ofbackend_resolve_run()(also forbase_envandtimeout).condathistreats the value the same way, also when it breaks them. It must not change, lock or register the environment.condathiscalls it with activation on, after it checked thatenv_direxists and has aconda-metadirectory. It calls it only when the backend object has this function. For a backend without it,run(),run_bin()andrun_pipeline()abort with classcondathis_env_path_unsupported_method(alsocondathis_backend_unsupported) whenmethodnames that backend.method = "auto"uses the first backend in the order of the optioncondathis.backend_prioritythat has it (micromamba by default). Default: aborts with classcondathis_backend_unsupported.
Every value is checked: a return value that breaks these rules aborts
with condathis_backend_invalid_return (also
condathis_backend_contract_violation), naming the backend.
Results
backend_create_env(), backend_install(), backend_remove_env() and
backend_clean_cache() may return NULL or a list. condathis copies
the elements status, stdout, stderr, timeout, pid and cmd
when present (exact names) and ignores everything else. Any other value
counts as an empty list. A non-zero status without an error counts as
a failure. cmd is reported as the command line that ran, so give the
exact command line, or leave it out. condathis shows the password and
the token of a URL in it as *****, as micromamba does. Hide them the
same way in what the backend prints and in its errors.
The element link_scripts_skipped of the results of
backend_create_env(), backend_install() and backend_remove_env(),
when present, is a data frame with the character columns package and
script ("pre-link", "post-link" or "pre-unlink"). It holds the
link scripts of packages that the backend did not run. condathis then
warns with class condathis_link_scripts_skipped after the call
succeeded, naming the packages (see condathis-conditions). A backend
that runs every link script, as micromamba does, leaves the element
out. Any other value aborts with condathis_backend_invalid_return.
Missing environments
condathis checks with backend_env_exists() that an environment
exists before it calls backend_install(), backend_remove_env(),
backend_list_packages() and backend_resolve_run(). A missing
environment aborts with class condathis_env_not_found before the
backend is called. So these functions do not get a missing environment
from condathis, except when backend_env_exists() failed. Then
condathis calls them anyway, and they must signal an error for a
missing environment. Give that error the class
condathis_env_not_found. condathis keeps the condathis_* classes
of a backend error when it re-raises it, so one handler catches a
missing environment on both sides. None of them may create an
environment.
Only run(), run_bin(), run_pipeline() and install_packages()
create a missing environment, and only the default "condathis-env":
they call backend_create_env() for an empty environment first.
For a missing environment, backend_env_exists() returns FALSE,
backend_list_envs() leaves the name out, and backend_get_env_dir()
returns the path anyway. None of them signals an error for it.
Errors
Signal an error of any class when an operation fails. condathis
re-raises it with the classes condathis_<function>_failed (for example
condathis_create_env_failed) and condathis_backend_error, plus every
condathis_* class of your error. It keeps your error as the parent.
Handlers still find your own classes with rlang::cnd_inherits(). When
you cannot honor an argument (for example env_file, or
channel_priority = "flexible"), also give your condition the class
condathis_backend_unsupported.
Do not give your own conditions the class condathis_backend_error:
condathis adds it when it re-raises your error. An error that has it
anyway is re-raised like any other error.
An error from backend_env_exists() means "cannot tell", never
"absent". A call that would change the environment then aborts with
condathis_backend_probe_failed. A query warns with
condathis_backend_query_failed and skips the backend.
See condathis-conditions for every class.
Channels
Search only the channels in channels and additional_channels, in that
order, and no default channels. channel_priority has its Conda
meaning. The channel column of backend_list_packages() is the
channel name, as micromamba reports it (for example "conda-forge"), or
the channel URL. condathis compares https://conda.anaconda.org/<name>
equal to <name>, and drops credentials (user:password@,
/t/<token>) and a platform subdirectory. It compares a file://
channel by its absolute path.
Child processes
condathis starts command with a copy of the session's environment
in which the CONDA_* and MAMBA_* variables listed in
condathis-options are cleared. It then applies env. For commands of
every backend other than "micromamba", CONDA_ENVS_PATH is the
parent directory of the environment directory from
backend_get_env_dir(), so a Conda tool run in the environment creates
named environments next to it. CONDA_PKGS_DIRS and MAMBA_PKGS_DIRS
are <root>/conda-pkgs for them when that parent directory is
<root>/envs, and "" otherwise. A Conda tool run in the environment
keeps its package cache in the backend's root, never in the user's home
directory or in the cache of another backend. clean_cache() empties
it. For an environment given by env_path(), they have the values of
the backend's own environments (those of "condathis-env"), not of the
directory that holds it. The "micromamba" backend sets these
variables to the envs/ and pkgs/ directories of its own root. A
backend whose commands need other values returns them in env.
Registering from a backend package
Put condathis in Imports, so it is loaded before your package, and
register from your package's .onLoad(). Also register from a hook on
the condathis load event. The registry lives in the condathis
namespace, so a registration is lost when condathis is unloaded and
loaded again, as during development with pkgload::load_all(). The hook
registers the backend on every later load of condathis. The hook list
belongs to base R and outlives your package, so .onUnload() must
remove the hook. Otherwise, the next load of condathis registers the
functions of an unloaded namespace.
register_mybackend <- function(...) {
condathis::register_backend("mybackend", new_backend_mybackend())
}
.onLoad <- function(libname, pkgname) {
register_mybackend()
setHook(packageEvent("condathis", "onLoad"), register_mybackend)
invisible(NULL)
}
.onUnload <- function(libpath) {
hook <- packageEvent("condathis", "onLoad")
setHook(
hook,
Filter(function(h) !identical(h, register_mybackend), getHook(hook)),
action = "replace"
)
if (isNamespaceLoaded("condathis")) {
condathis::unregister_backend("mybackend")
}
invisible(NULL)
}register_mybackend() must be a function at the top level of your
package, so that identical() finds it. A function created inside
.onLoad() is a new object every time. Call condathis functions with
condathis:: instead of importing them in your NAMESPACE. After a
reload of condathis, imported copies would be the functions of the
old namespace.
register_backend() validates the backend, so after a condathis
upgrade that changes the contract, the call in .onLoad() fails and
your package does not load. The error names what to change. R runs the
hook under try(), so a failing hook only prints its error and
condathis still loads.
condathis knows some backend packages by name: rattlerthis provides
the backend "rattler". condathis loads such a package when it is
installed and one of these holds:
methodnames its backend.Its backend has environments (a directory in
<backend_root_dir(name)>/envs), so thatmethod = "auto"finds them without a call tolibrary().The option
condathis.backend_prioritynames its backend, andmethod = "auto"chooses the backend of a new environment.
condathis does not depend on these packages. When such a package is
not installed, or fails to load, a method that names its backend
aborts with class condathis_backend_not_installed or
condathis_backend_load_failed. A change to one of its environments
aborts the same way. The queries skip its environments with a warning of
the same class (see condathis-conditions). The package must register
its backend from its .onLoad(). A package that loads without
registering it counts as failed.
See also
condathis-conditions for the condition classes, and condathis-options for the variables of child processes.
Other backends:
backend_root_dir(),
list_registered_backend_names(),
unregister_backend()
Examples
# A minimal backend: the three required functions, which this example
# does not implement. It reports that it is unavailable, so
# `method = "auto"` never selects it. condathis supplies the other
# functions: they look for environments in
# `backend_root_dir("mybackend")/envs`. A backend package builds this
# object in its own constructor, for example `new_backend_mybackend()` in
# *Registering from a backend package* of `?register_backend`.
not_implemented <- function(...) {
stop("Not implemented by this example backend.")
}
mybackend <- structure(
list(
backend_create_env = not_implemented,
backend_install = not_implemented,
backend_resolve_run = not_implemented,
backend_available = function(backend) FALSE
),
class = c("condathis_backend_mybackend", "condathis_backend")
)
condathis::register_backend("mybackend", mybackend)
condathis::list_registered_backend_names()
#> [1] "micromamba" "mybackend"
condathis::unregister_backend("mybackend")
condathis::list_registered_backend_names()
#> [1] "micromamba"