The environment¶
Already know this?
- When a child process starts, whose environment does it get, and does it see the parent's later changes?
- Is
os.environshared between threads? - What does
os.putenvnot do thatos.environ["X"] = …does?
All three easy? Skip to The shell.
The concept¶
The environment is a small string-to-string map every process carries:
PATH, HOME, your API_KEY. Its defining property is how it moves:
a child process receives a copy at spawn, and from that moment the two
maps are independent: the parent's later edits never reach a running
child, and nothing a child sets ever flows back up. Environment flows
down, once, at spawn.
Inside one process, though, there is just the one map, shared by every
thread, because os.environ is a process global like the cwd. And it has a trap
of its own: os.putenv writes the C-level environment without updating
os.environ, so a program can end up with two views of its own env that
disagree. Plain Python advice, footman aside: assign through os.environ,
never putenv.
Why it matters to a task runner¶
Two parallel tasks writing os.environ race exactly like two threads
writing any shared dict: last write wins, at a time nobody chose. And a
runner that can run the same tool two ways has a subtler problem waiting: a
tool run in-process reads the live os.environ, while the
subprocess form of the very same call receives a constructed env=,
two paths of one tool call reading different worlds, so correct code
breaks by switching paths.
What footman does about it¶
For the run's duration, os.environ goes through footman's environment
router, the same move as its stdout router applied to a second global:
- Each task owns a whole environment, copied from the run's at the
boundary, not a diff against something.
os.environanswers from it, every child footman spawns receives it, and the subprocess path gets the same value the in-process path reads. The two paths read one world. - Writes scope to the task.
os.environ["API_KEY"] = "…"is visible to this task's reads and to every child it spawns, and invisible to siblings. A one-time note names the deliberate spellings:env=for one call,ctx.envfor the task. - Deleting is ordinary.
del os.environ["NO_COLOR"]removes the key from this task's environment and from the children it spawns after, while a sibling's copy is untouched, because a task holds a value, not an overlay with nothing to say for absence. That matters for variables read by presence rather than value, where setting""is not the same as unsetting. os.putenv/os.unsetenvare taught errors, because they bypassos.environeven in plain Python, so nothing could scope them.- Two variables are never inherited.
PYTHONHOMEandPYTHONEXECUTABLEdescribe footman's interpreter, and a tool that has its own has no use for them: a console script handed the wrong one dies during start-up, before it can say why. So a task starts without them whatever the surrounding process holds. Set either deliberately and it is passed on untouched: what is dropped is the inheriting, not the variable.PYTHONPATHis left alone, becausePYTHONPATH=srcis a thing people mean.
Handing one to a child¶
env= is the child's environment, exactly as subprocess means it: what
you pass is what it gets. Both standard idioms work, and neither needs a
footman-specific spelling:
run(cmd, env={**os.environ, "CI": "1"}) # add to what this task has
leaner = dict(os.environ) # or take something away
leaner.pop("GITHUB_TOKEN", None) # no token for a linter
run(cmd, env=leaner)
Inside a task os.environ is the task's environment, so dict(os.environ)
is exactly what a child would otherwise receive, so the copy is a faithful
starting point rather than an approximation. Omit env= and the child simply
inherits the task's.
Outside a run, os.environ behaves exactly as stock Python. And because
environment flows down at spawn, everything composes: a scoped write made
with plain os.environ rides into a raw subprocess.Popen child just as
it would into run(): the child's copy is cut from the task's world, not
the process's.
One variable, four hops¶
Where an environment variable travels is easiest to see by following one all the way down. Say a task selects a test tier:
@task
def gate():
os.environ["TIER"] = "smoke" # hop 1: this task's overlay
run("pytest tests/fast") # hop 2: the child sees TIER
instance_checks() # hop 3: a nested task call
Hop 1, the task overlay. The write scopes to gate and everything
under it. Siblings running in parallel never see TIER; the process
outside the run never does either.
Hop 2, a run() child. The child's environment is cut from the
task's world, so TIER rides in. This is the hop most writes are aimed
at, and for exactly one child the tighter spelling is
run(cmd, env={**os.environ, "TIER": "smoke"}), scoped to the call, with no
overlay write at all.
Hop 3, a nested task call. instance_checks() gets its own context,
and that context's environment is a copy of the caller's, overlay
included. TIER flows down into the callee (writes never travel back
up), and from the callee into everything it runs.
Hop 4, the children of children. If instance_checks runs pytest,
and those tests spawn subprocesses of their own, plain OS inheritance
carries TIER the rest of the way; footman is no longer involved, and
nothing strips it.
The bite is always the same shape: a variable aimed at hop 2 is still
alive at hop 4, where some distant process changes behaviour on it: a
test runner deselecting suites, a tool switching profiles. Flow-down
never stops at a task boundary, so scope the write to match the reader:
env= on the one call that needs it, or ctx.env.pop("TIER", None) /
del os.environ["TIER"] before the nested call when the overlay was the
right tool but the callee must not inherit it.
The one rule¶
Environment flows down at spawn. Say what a child should see with
env= or ctx.env, and never expect a write to travel sideways.