Coverage for structured_tutorials / runners / local.py: 100%
151 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-21 19:08 +0100
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-21 19:08 +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 CommandOutputTestError, 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 if test.stream == "stderr":
37 value = proc.stderr
38 else:
39 value = proc.stdout
41 if (match := test.regex.search(value)) is not None:
42 self.context.update({k: v.decode("utf-8") for k, v in match.groupdict().items()})
43 return
44 else:
45 decoded = value.decode("utf-8")
46 raise CommandOutputTestError(f"Process did not have the expected output: '{decoded}'")
48 # If an initial delay is configured, wait that long
49 if test.delay > 0:
50 time.sleep(test.delay)
52 tries = 0
53 while tries <= test.retry:
54 tries += 1
56 if isinstance(test, TestCommandModel):
57 test_proc = self.run_shell_command(test.command, show_output=test.show_output)
59 if test.status_code == test_proc.returncode:
60 return
61 else:
62 log.error("%s: Test command failed.", shlex.join(test_proc.args))
63 else:
64 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
65 try:
66 s.connect((test.host, test.port))
67 except Exception:
68 log.error("%s:%s: failed to connect.", test.host, test.port)
69 else:
70 return
72 wait = test.backoff_factor * (2 ** (tries - 1))
73 if wait > 0 and tries <= test.retry:
74 time.sleep(wait)
76 raise CommandTestError("Test did not pass")
78 def run_commands(self, part: CommandsPartModel) -> None:
79 for command_config in part.commands:
80 if command_config.run.skip:
81 continue
83 # Capture output if any test is for the output.
84 capture_output = any(isinstance(test, TestOutputModel) for test in command_config.run.test)
86 proc_input = None
87 if stdin_config := command_config.run.stdin:
88 if stdin_config.contents:
89 proc_input = self.render(stdin_config.contents).encode("utf-8")
90 elif stdin_config.template: # source path, but template=True
91 assert stdin_config.source is not None
92 with open(self.tutorial.tutorial_root / stdin_config.source) as stream:
93 stdin_template = stream.read()
94 proc_input = self.render(stdin_template).encode("utf-8")
96 # Run the command and check status code
97 if (
98 command_config.run.stdin
99 and command_config.run.stdin.source
100 and not command_config.run.stdin.template
101 ):
102 with open(self.tutorial.tutorial_root / command_config.run.stdin.source, "rb") as stdin:
103 proc = self.run_shell_command(
104 command_config.command,
105 show_output=command_config.run.show_output,
106 capture_output=capture_output,
107 stdin=stdin,
108 )
109 else:
110 proc = self.run_shell_command(
111 command_config.command,
112 show_output=command_config.run.show_output,
113 capture_output=capture_output,
114 input=proc_input,
115 )
117 # Update list of cleanup commands
118 self.cleanup = list(command_config.run.cleanup) + self.cleanup
120 # Handle errors in commands
121 if proc.returncode != command_config.run.status_code:
122 raise RuntimeError(
123 f"{command_config.command} failed with return code {proc.returncode} "
124 f"(expected: {command_config.run.status_code})."
125 )
127 # Update the context from update_context
128 self.context.update(command_config.run.update_context)
130 if command_config.run.chdir is not None:
131 log.info("Changing working directory to %s.", command_config.run.chdir)
132 os.chdir(command_config.run.chdir)
134 # Run test commands
135 for test_command_config in command_config.run.test:
136 self.run_test(test_command_config, proc)
138 def write_file(self, part: FilePartModel) -> None:
139 """Write a file."""
140 raw_destination = self.render(part.destination)
141 destination = Path(raw_destination)
143 if raw_destination.endswith(os.path.sep):
144 # Model validation already validates that the destination does not look like a directory, if no
145 # source is set, but this could be tricked if the destination is a template.
146 if not part.source:
147 raise RuntimeError(
148 f"{raw_destination}: Destination is directory, but no source given to derive filename."
149 )
151 destination.mkdir(parents=True, exist_ok=True)
152 destination = destination / part.source.name
153 elif destination.exists():
154 raise RuntimeError(f"{destination}: Destination already exists.")
156 # Create any parent directories
157 destination.parent.mkdir(parents=True, exist_ok=True)
159 # If template=False and source is set, we just copy the file as is, without ever reading it
160 if not part.template and part.source:
161 shutil.copy(part.source, destination)
162 return
164 if part.source:
165 with open(self.tutorial.tutorial_root / part.source) as source_stream:
166 template = source_stream.read()
167 else:
168 assert isinstance(part.contents, str) # assured by model validation
169 template = part.contents
171 if part.template:
172 contents = self.render(template)
173 else:
174 contents = template
176 with open(destination, "w") as destination_stream:
177 destination_stream.write(contents)
179 def run_prompt(self, part: PromptModel) -> None:
180 prompt = self.render(part.prompt).strip() + " "
182 if part.response == "enter":
183 input(prompt)
184 else: # type == confirm
185 valid_inputs = ("n", "no", "yes", "y", "")
186 while (response := input(prompt).strip().lower()) not in valid_inputs:
187 print(f"Please enter a valid value ({'/'.join(valid_inputs)}).")
189 if response in ("n", "no") or (response == "" and not part.default):
190 error = self.render(part.error, response=response)
191 raise PromptNotConfirmedError(error)
193 def run_alternative(self, part: AlternativeModel) -> None:
194 selected = set(self.alternatives) & set(part.alternatives)
196 # Note: The CLI agent already verifies this - just assert this to be sure.
197 assert len(selected) <= 1, "More then one part selected."
199 if selected:
200 selected_part = part.alternatives[next(iter(selected))]
201 if isinstance(selected_part, CommandsPartModel):
202 self.run_commands(selected_part)
203 elif isinstance(selected_part, FilePartModel):
204 self.write_file(selected_part)
205 else: # pragma: no cover
206 raise RuntimeError(f"{selected_part} is not supported as alternative.")
208 def run_parts(self) -> None:
209 for part in self.tutorial.parts:
210 if part.name: # pragma: no cover
211 part_log.info(part.name)
212 else:
213 part_log.info(f"Running part {part.id}...")
215 if isinstance(part, PromptModel):
216 if self.interactive:
217 self.run_prompt(part)
218 continue
220 if part.run.skip:
221 continue
223 if isinstance(part, CommandsPartModel):
224 self.run_commands(part)
225 elif isinstance(part, FilePartModel):
226 self.write_file(part)
227 elif isinstance(part, AlternativeModel):
228 self.run_alternative(part)
229 else: # pragma: no cover
230 raise RuntimeError(f"{part} is not a tutorial part")
232 self.context.update(part.run.update_context)
234 def run(self) -> None:
235 if self.tutorial.configuration.run.temporary_directory:
236 with tempfile.TemporaryDirectory() as tmpdir_name:
237 log.info("Switching to temporary directory: %s", tmpdir_name)
238 self.context["cwd"] = self.context["temp_dir"] = Path(tmpdir_name)
239 self.context["orig_cwd"] = Path.cwd()
241 with chdir(tmpdir_name), cleanup(self):
242 self.run_parts()
243 elif self.tutorial.configuration.run.git_export:
244 with tempfile.TemporaryDirectory() as tmpdir_name:
245 work_dir = git_export(tmpdir_name)
246 log.info("Creating git export at: %s", work_dir)
247 self.context["cwd"] = self.context["export_dir"] = work_dir
248 self.context["orig_cwd"] = Path.cwd()
250 with chdir(work_dir), cleanup(self):
251 self.run_parts()
252 else:
253 with cleanup(self):
254 self.run_parts()