Skip to content

Commit 5d00323

Browse files
committed
add ability to clear/update the environment for commands
1 parent 2322a7e commit 5d00323

File tree

7 files changed

+88
-11
lines changed

7 files changed

+88
-11
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,13 @@ Configure the tutorial that is being displayed - this will not show any output:
7373

7474
## TODO
7575

76-
* Test file existance or something like that
77-
* Pre/post text for each part
7876
* Running commands:
7977

8078
* Clear env
8179
* Add env (value is template)
8280

81+
* Pre/post text for each part
82+
* Test file existence or something like that
8383
* Platform independent "echo" step (useful for debugging/testing)
8484
* Run in vagrant
8585

structured_tutorials/models/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ class CommandBaseModel(BaseModel):
4343
model_config = ConfigDict(extra="forbid")
4444

4545
status_code: Annotated[int, Field(ge=0, le=255)] = 0
46+
clear_environment: bool = Field(default=False, description="Clear the environment.")
47+
environment: dict[str, Any] = Field(
48+
default_factory=dict, description="Additional environment variables for the process."
49+
)
4650
show_output: bool = Field(
4751
default=True, description="Set to `False` to always hide the output of this command."
4852
)

structured_tutorials/runners/base.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import abc
77
import io
88
import logging
9+
import os
910
import shlex
1011
import subprocess
1112
import sys
@@ -75,12 +76,12 @@ def test_output(self, proc: subprocess.CompletedProcess[bytes], test: TestOutput
7576
try:
7677
check_count(value.splitlines(), test.line_count)
7778
except ValueError as ex:
78-
raise CommandOutputTestError(f"Line count error: {ex}")
79+
raise CommandOutputTestError(f"Line count error: {ex}") from ex
7980
if test.character_count:
8081
try:
8182
check_count(value, test.character_count)
8283
except ValueError as ex:
83-
raise CommandOutputTestError(f"Character count error: {ex}")
84+
raise CommandOutputTestError(f"Character count error: {ex}") from ex
8485

8586
def validate_alternatives(self) -> None:
8687
"""Validate that for each alternative part, an alternative was selected."""
@@ -104,11 +105,14 @@ def run_shell_command(
104105
capture_output: bool = False,
105106
stdin: int | io.BufferedReader | None = None,
106107
input: bytes | None = None,
108+
environment: dict[str, Any] | None = None,
109+
clear_environment: bool = False,
107110
) -> CompletedProcess[bytes]:
108111
# Only show output if runner itself is not configured to hide all output
109112
if show_output:
110113
show_output = self.show_command_output
111114

115+
# Configure stdout/stderr streams
112116
if capture_output:
113117
stdout: int | None = subprocess.PIPE
114118
stderr: int | None = subprocess.PIPE
@@ -117,6 +121,20 @@ def run_shell_command(
117121
else:
118122
stdout = stderr = subprocess.DEVNULL
119123

124+
# Configure environment
125+
if environment:
126+
# If environment is passed, we render variables as templates
127+
environment = {k: self.render(v) for k, v in environment.items()}
128+
elif environment is None: # pragma: no cover # dict is always passed directly
129+
# just to simplify the next branch -> environment is always a dict
130+
environment = {}
131+
if clear_environment:
132+
env = environment
133+
elif environment:
134+
env = {**os.environ, **environment}
135+
else:
136+
env = None
137+
120138
# Render the command (args) as template
121139
command = self.render_command(command)
122140

@@ -127,7 +145,9 @@ def run_shell_command(
127145
logged_command = shlex.join(logged_command)
128146

129147
command_logger.info(logged_command)
130-
proc = subprocess.run(command, shell=shell, stdin=stdin, input=input, stdout=stdout, stderr=stderr)
148+
proc = subprocess.run(
149+
command, shell=shell, stdin=stdin, input=input, stdout=stdout, stderr=stderr, env=env
150+
)
131151

132152
# If we want to show the output and capture it at the same time, we need can only show the streams
133153
# separately at the end.

structured_tutorials/runners/local.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import time
1414
from pathlib import Path
1515

16-
from structured_tutorials.errors import CommandOutputTestError, CommandTestError, PromptNotConfirmedError
16+
from structured_tutorials.errors import CommandTestError, PromptNotConfirmedError
1717
from structured_tutorials.models.parts import AlternativeModel, CommandsPartModel, FilePartModel, PromptModel
1818
from structured_tutorials.models.tests import TestCommandModel, TestOutputModel, TestPortModel
1919
from structured_tutorials.runners.base import RunnerBase
@@ -45,7 +45,12 @@ def run_test(
4545
tries += 1
4646

4747
if isinstance(test, TestCommandModel):
48-
test_proc = self.run_shell_command(test.command, show_output=test.show_output)
48+
test_proc = self.run_shell_command(
49+
test.command,
50+
show_output=test.show_output,
51+
environment=test.environment,
52+
clear_environment=test.clear_environment,
53+
)
4954

5055
if test.status_code == test_proc.returncode:
5156
return
@@ -96,13 +101,17 @@ def run_commands(self, part: CommandsPartModel) -> None:
96101
show_output=command_config.run.show_output,
97102
capture_output=capture_output,
98103
stdin=stdin,
104+
environment=command_config.run.environment,
105+
clear_environment=command_config.run.clear_environment,
99106
)
100107
else:
101108
proc = self.run_shell_command(
102109
command_config.command,
103110
show_output=command_config.run.show_output,
104111
capture_output=capture_output,
105112
input=proc_input,
113+
environment=command_config.run.environment,
114+
clear_environment=command_config.run.clear_environment,
106115
)
107116

108117
# Update list of cleanup commands

structured_tutorials/utils.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,12 @@ def cleanup(runner: "RunnerBase") -> Iterator[None]:
7676
log.info("Running cleanup commands.")
7777

7878
for command_config in runner.cleanup:
79-
runner.run_shell_command(command_config.command, command_config.show_output)
79+
runner.run_shell_command(
80+
command_config.command,
81+
command_config.show_output,
82+
environment=command_config.environment,
83+
clear_environment=command_config.clear_environment,
84+
)
8085

8186

8287
def git_export(destination: str | Path, ref: str = "HEAD") -> Path:
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
parts:
2+
- commands:
3+
- command: echo 1 $VARIABLE
4+
run:
5+
environment:
6+
VARIABLE: VALUE 1
7+
- command: echo 2 $VARIABLE
8+
run:
9+
clear_environment: true
10+
environment:
11+
VARIABLE: VALUE 2

tests/runners/local/test_command.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def test_command_as_list(fp: FakeProcess, runner: LocalTutorialRunner) -> None:
6464
recorder_test = fp.register(["ls", "test with spaces"])
6565
recorder_cleanup = fp.register(["ls", "cleanup with spaces"])
6666
runner.run()
67-
expected = {"shell": False, "stdin": None, "stderr": None, "stdout": None}
67+
expected = {"shell": False, "stdin": None, "stderr": None, "stdout": None, "env": None}
6868
assert recorder_main.calls[0].kwargs == expected
6969
assert recorder_test.calls[0].kwargs == expected
7070
assert recorder_cleanup.calls[0].kwargs == expected
@@ -103,7 +103,13 @@ def test_command_hide_output(fp: FakeProcess, runner: LocalTutorialRunner) -> No
103103
recorder_test = fp.register("ls test")
104104
recorder_cleanup = fp.register("ls cleanup")
105105
runner.run()
106-
expected = {"shell": True, "stdin": None, "stderr": subprocess.DEVNULL, "stdout": subprocess.DEVNULL}
106+
expected = {
107+
"shell": True,
108+
"stdin": None,
109+
"stderr": subprocess.DEVNULL,
110+
"stdout": subprocess.DEVNULL,
111+
"env": None,
112+
}
107113
assert recorder_main.calls[0].kwargs == expected
108114
assert recorder_test.calls[0].kwargs == expected
109115
assert recorder_cleanup.calls[0].kwargs == expected
@@ -116,7 +122,13 @@ def test_command_capture_output(
116122
"""Test running a commands with capturing the output."""
117123
recorder = fp.register("echo foo bar bla", stdout="foo bar bla", stderr="foo bla bla")
118124
runner.run()
119-
expected = {"shell": True, "stdin": None, "stderr": subprocess.PIPE, "stdout": subprocess.PIPE}
125+
expected = {
126+
"shell": True,
127+
"stdin": None,
128+
"stderr": subprocess.PIPE,
129+
"stdout": subprocess.PIPE,
130+
"env": None,
131+
}
120132
assert recorder.calls[0].kwargs == expected
121133
output = capsys.readouterr()
122134
assert output.out == "--- stdout ---\nfoo bar bla\n--- stderr ---\nfoo bla bla\n"
@@ -156,6 +168,22 @@ def test_command_with_invalid_output(fp: FakeProcess, runner: LocalTutorialRunne
156168
runner.run()
157169

158170

171+
@pytest.mark.tutorial("command-simple-env")
172+
def test_environment(fp: FakeProcess, runner: LocalTutorialRunner) -> None:
173+
"""Test running a commands with environment variables."""
174+
recorder1 = fp.register("echo 1 $VARIABLE")
175+
recorder2 = fp.register("echo 2 $VARIABLE")
176+
runner.run()
177+
assert recorder1.calls[0].kwargs["env"]["VARIABLE"] == "VALUE 1"
178+
assert recorder2.calls[0].kwargs == {
179+
"shell": True,
180+
"stdin": None,
181+
"stderr": None,
182+
"stdout": None,
183+
"env": {"VARIABLE": "VALUE 2"},
184+
}
185+
186+
159187
@pytest.mark.doc_tutorial("test-command")
160188
def test_test_commands_with_command_error(fp: FakeProcess, doc_runner: LocalTutorialRunner) -> None:
161189
"""Test the cleanup from docs."""

0 commit comments

Comments
 (0)