Ambient.Env (Ambient v0.1.0)

Copy Markdown View Source

Overridable OS environment variables.

In production get/2 is System.get_env/2. In tests, put/2 sets a value for the calling process and everything it spawns – without touching the actual environment.

Why not System.put_env/2?

It mutates the whole VM. Two async: true tests setting the same variable clobber each other, and a leaked value survives into every later test in the run; on_exit cleanup is easy to forget and easy to get wrong when a test fails midway. Ambient.Env.put/2 is process-scoped, inherited through the $callers chain, and cleared automatically when the process exits – and the real OS environment is never touched, so a concurrent test reading the same variable still sees the real value.

This is the same argument Ambient.Config makes against Application.put_env/3, applied one layer down.

Production code

Ambient.Env.get("DATABASE_URL")
Ambient.Env.get("PORT", "4000")
Ambient.Env.fetch!("SECRET_KEY_BASE")

Read variables through these rather than System.get_env/1 anywhere the value should be testable. Ambient.Credo.NoDirectEnv can enforce it.

Runtime reads only

An override can only affect a read that happens while it is in scope. Config resolved at boot (config/runtime.exs) or at compile time has already been read, so overriding the variable later changes nothing. Wrap the read in a function your code calls when it needs the value.

Test usage

Ambient.Env.put("FEATURE_X", "true")
Ambient.Env.put_all(%{"REGION" => "eu-west-1", "TIER" => "premium"})
Ambient.Env.unset("HOME")            # override it as absent, though it is set
Ambient.Env.revert("HOME")           # drop the override, see the real value
Ambient.Env.reset()                  # drop every override this process set

# for a long-lived process that reads env in its own process:
Ambient.Env.allow(genserver_pid)

Register the table once in test/test_helper.exs:

Ambient.start_servers([Ambient.Env])

Summary

Functions

Authorise child_pid to read owner_pid's overrides. For long-lived processes that don't appear in the $callers chain.

Drop every override this process owns in this module's table.

Drop this process's override for key. No-op if there isn't one.

{:ok, value} if var is set (or overridden), :error otherwise. Mirrors System.fetch_env/1.

The value of var, raising if it is unset. Mirrors System.fetch_env!/1, including the exception type.

The value of environment variable var, or default when it is unset.

Whether an override for key is in scope for the calling process.

Override var for this process and everything it spawns. The real environment is untouched.

Override several variables at once. Takes anything enumerating {name, value} pairs of binaries – a map or a list. Not a keyword list: variable names are strings, not atoms.

Set a process-local override for key. Auto-cleaned when the process exits.

Drop every override this process set. Like revert/1, an inherited override from an ancestor still applies afterwards.

Drop this process's override for var, so get/2 resolves normally again.

Return this module's table to private, process-scoped mode.

Make owner_pid's overrides the ones every process reads. async: false only – see Ambient.ProcessOverride.set_shared/2.

Override var as unset for this process, whatever the real environment says. get/2 then returns its default and fetch/1 returns :error.

Functions

allow(child_pid, owner_pid \\ self())

@spec allow(pid(), pid()) :: :ok

Authorise child_pid to read owner_pid's overrides. For long-lived processes that don't appear in the $callers chain.

delete_all()

@spec delete_all() :: :ok

Drop every override this process owns in this module's table.

delete_override(key)

@spec delete_override(term()) :: :ok

Drop this process's override for key. No-op if there isn't one.

fetch(var)

@spec fetch(String.t()) :: {:ok, String.t()} | :error

{:ok, value} if var is set (or overridden), :error otherwise. Mirrors System.fetch_env/1.

fetch!(var)

@spec fetch!(String.t()) :: String.t()

The value of var, raising if it is unset. Mirrors System.fetch_env!/1, including the exception type.

get(var, default \\ nil)

@spec get(String.t(), term()) :: term()

The value of environment variable var, or default when it is unset.

Delegates to System.get_env/2 unless overridden – and in a build without overrides compiled in, is System.get_env/2.

overridden?(key)

@spec overridden?(term()) :: boolean()

Whether an override for key is in scope for the calling process.

put(var, value)

@spec put(String.t(), String.t()) :: :ok

Override var for this process and everything it spawns. The real environment is untouched.

put_all(vars)

@spec put_all(Enumerable.t()) :: :ok

Override several variables at once. Takes anything enumerating {name, value} pairs of binaries – a map or a list. Not a keyword list: variable names are strings, not atoms.

put_override(key, value)

@spec put_override(term(), term()) :: :ok

Set a process-local override for key. Auto-cleaned when the process exits.

reset()

@spec reset() :: :ok

Drop every override this process set. Like revert/1, an inherited override from an ancestor still applies afterwards.

revert(var)

@spec revert(String.t()) :: :ok

Drop this process's override for var, so get/2 resolves normally again.

"Normally" means the usual chain: if an ancestor still has an override for var, that one applies; only when nothing is left does the real environment show through.

set_private()

@spec set_private() :: :ok

Return this module's table to private, process-scoped mode.

set_shared(owner_pid \\ self())

@spec set_shared(pid()) :: :ok

Make owner_pid's overrides the ones every process reads. async: false only – see Ambient.ProcessOverride.set_shared/2.

unset(var)

@spec unset(String.t()) :: :ok

Override var as unset for this process, whatever the real environment says. get/2 then returns its default and fetch/1 returns :error.

This is how you test the "variable absent" path for something that really is set. Note it writes an override; to drop one and fall back to the real value, use revert/1.