Coverage for structured_tutorials / runners / local.py: 100%
149 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-26 12:41 +0100
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-26 12:41 +0100
1# Copyright (c) 2025 Mathias Ertl
2# Licensed under the MIT License. See LICENSE file for details.
4"""Runner that runs a tutorial on the local machine."""
6import logging
7import os
8import shlex
9import shutil
10import socket
11import subprocess
12import tempfile
13import time
14from pathlib import Path
16from structured_tutorials.errors import CommandTestError, PromptNotConfirmedError
17from structured_tutorials.models.parts import AlternativeModel, CommandsPartModel, FilePartModel, PromptModel
18from structured_tutorials.models.tests import TestCommandModel, TestOutputModel, TestPortModel
19from structured_tutorials.runners.base import RunnerBase
20from structured_tutorials.utils import chdir, cleanup, git_export
22log = logging.getLogger(__name__)
23part_log = logging.getLogger("part")
26class LocalTutorialRunner(RunnerBase):
27 """Runner implementation that runs a tutorial on the local machine."""
29 def run_test(
30 self,
31 test: TestCommandModel | TestPortModel | TestOutputModel,
32 proc: subprocess.CompletedProcess[bytes],
33 ) -> None:
34 # If the test is for an output stream, we can run it right away (the process has already finished).
35 if isinstance(test, TestOutputModel):
36 self.test_output(proc, test)
37 return
39 # If an initial delay is configured, wait that long
40 if test.delay > 0:
41 time.sleep(test.delay)
43 tries = 0
44 while tries <= test.retry:
45 tries += 1
47 if isinstance(test, TestCommandModel):
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 )
55 # Update environment regardless of success of command
56 self.environment.update({k: self.render(v) for k, v in test.update_environment.items()})
58 if test.status_code == test_proc.returncode:
59 return
60 else:
61 log.error("%s: Test command failed.", shlex.join(test_proc.args))
62 else:
63 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
64 try:
65 s.connect((test.host, test.port))
66 except Exception:
67 log.error("%s:%s: failed to connect.", test.host, test.port)
68 else:
69 return
71 wait = test.backoff_factor * (2 ** (tries - 1))
72 if wait > 0 and tries <= test.retry:
73 time.sleep(wait)
75 raise CommandTestError("Test did not pass")
77 def run_commands(self, part: CommandsPartModel) -> None:
78 for command_config in part.commands:
79 if command_config.run.skip:
80 continue
82 # Capture output if any test is for the output.
83 capture_output = any(isinstance(test, TestOutputModel) for test in command_config.run.test)
85 proc_input = None
86 if stdin_config := command_config.run.stdin:
87 if stdin_config.contents:
88 proc_input = self.render(stdin_config.contents).encode("utf-8")
89 elif stdin_config.template: # source path, but template=True
90 assert stdin_config.source is not None
91 with open(self.tutorial.tutorial_root / stdin_config.source) as stream:
92 stdin_template = stream.read()
93 proc_input = self.render(stdin_template).encode("utf-8")
95 # Run the command and check status code
96 if (
97 command_config.run.stdin
98 and command_config.run.stdin.source
99 and not command_config.run.stdin.template
100 ):
101 with open(self.tutorial.tutorial_root / command_config.run.stdin.source, "rb") as stdin:
102 proc = self.run_shell_command(
103 command_config.command,
104 show_output=command_config.run.show_output,
105 capture_output=capture_output,
106 stdin=stdin,
107 environment=command_config.run.environment,
108 clear_environment=command_config.run.clear_environment,
109 )
110 else:
111 proc = self.run_shell_command(
112 command_config.command,
113 show_output=command_config.run.show_output,
114 capture_output=capture_output,
115 input=proc_input,
116 environment=command_config.run.environment,
117 clear_environment=command_config.run.clear_environment,
118 )
120 # Update list of cleanup commands
121 self.cleanup = list(command_config.run.cleanup) + self.cleanup
123 # Handle errors in commands
124 if proc.returncode != command_config.run.status_code:
125 raise RuntimeError(
126 f"{command_config.command} failed with return code {proc.returncode} "
127 f"(expected: {command_config.run.status_code})."
128 )
130 # Update the context from update_context
131 self.context.update(command_config.run.update_context)
133 if (command_chdir := command_config.run.chdir) is not None:
134 rendered_command_chdir = self.render(str(command_chdir))
135 rendered_command_chdir = self.render(str(command_chdir))
136 log.info("Changing working directory to %s.", command_chdir)
137 os.chdir(rendered_command_chdir)
139 # Run test commands
140 for test_command_config in command_config.run.test:
141 self.run_test(test_command_config, proc)
143 # Update environment (after test commands - they may update the context)
144 self.environment.update(
145 {k: self.render(v) for k, v in command_config.run.update_environment.items()}
146 )
148 def write_file(self, part: FilePartModel) -> None:
149 """Write a file."""
150 raw_destination = self.render(part.destination)
151 destination = Path(raw_destination)
153 if raw_destination.endswith(os.path.sep):
154 # Model validation already validates that the destination does not look like a directory, if no
155 # source is set, but this could be tricked if the destination is a template.
156 if not part.source:
157 raise RuntimeError(
158 f"{raw_destination}: Destination is directory, but no source given to derive filename."
159 )
161 destination.mkdir(parents=True, exist_ok=True)
162 destination = destination / part.source.name
163 elif destination.exists():
164 raise RuntimeError(f"{destination}: Destination already exists.")
166 # Create any parent directories
167 destination.parent.mkdir(parents=True, exist_ok=True)
169 # If template=False and source is set, we just copy the file as is, without ever reading it
170 if not part.template and part.source:
171 shutil.copy(part.source, destination)
172 return
174 if part.source:
175 with open(self.tutorial.tutorial_root / part.source) as source_stream:
176 template = source_stream.read()
177 else:
178 assert isinstance(part.contents, str) # assured by model validation
179 template = part.contents
181 if part.template:
182 contents = self.render(template)
183 else:
184 contents = template
186 with open(destination, "w") as destination_stream:
187 destination_stream.write(contents)
189 def run_prompt(self, part: PromptModel) -> None:
190 prompt = self.render(part.prompt).strip() + " "
192 if part.response == "enter":
193 input(prompt)
194 else: # type == confirm
195 valid_inputs = ("n", "no", "yes", "y", "")
196 while (response := input(prompt).strip().lower()) not in valid_inputs:
197 print(f"Please enter a valid value ({'/'.join(valid_inputs)}).")
199 if response in ("n", "no") or (response == "" and not part.default):
200 error = self.render(part.error, response=response)
201 raise PromptNotConfirmedError(error)
203 def run_alternative(self, part: AlternativeModel) -> None:
204 selected = set(self.alternatives) & set(part.alternatives)
206 # Note: The CLI agent already verifies this - just assert this to be sure.
207 assert len(selected) <= 1, "More then one part selected."
209 if selected:
210 selected_part = part.alternatives[next(iter(selected))]
211 if isinstance(selected_part, CommandsPartModel):
212 self.run_commands(selected_part)
213 elif isinstance(selected_part, FilePartModel):
214 self.write_file(selected_part)
215 else: # pragma: no cover
216 raise RuntimeError(f"{selected_part} is not supported as alternative.")
218 def run_parts(self) -> None:
219 for part in self.tutorial.parts:
220 if isinstance(part, PromptModel):
221 if self.interactive:
222 self.run_prompt(part)
223 continue
224 if part.run.skip:
225 continue
227 if part.name: # pragma: no cover
228 part_log.info(part.name)
229 else:
230 part_log.info(f"Running part {part.id}...")
232 if isinstance(part, CommandsPartModel):
233 self.run_commands(part)
234 elif isinstance(part, FilePartModel):
235 self.write_file(part)
236 elif isinstance(part, AlternativeModel):
237 self.run_alternative(part)
238 else: # pragma: no cover
239 raise RuntimeError(f"{part} is not a tutorial part")
241 self.context.update(part.run.update_context)
243 def run(self) -> None:
244 if self.tutorial.configuration.run.temporary_directory:
245 with tempfile.TemporaryDirectory() as tmpdir_name:
246 log.info("Switching to temporary directory: %s", tmpdir_name)
247 self.context["cwd"] = self.context["temp_dir"] = Path(tmpdir_name)
248 self.context["orig_cwd"] = Path.cwd()
250 with chdir(tmpdir_name), cleanup(self):
251 self.run_parts()
252 elif self.tutorial.configuration.run.git_export:
253 with tempfile.TemporaryDirectory() as tmpdir_name:
254 work_dir = git_export(tmpdir_name)
255 log.info("Creating git export at: %s", work_dir)
256 self.context["cwd"] = self.context["export_dir"] = work_dir
257 self.context["orig_cwd"] = Path.cwd()
259 with chdir(work_dir), cleanup(self):
260 self.run_parts()
261 else:
262 with cleanup(self):
263 self.run_parts()