Coverage for structured_tutorials / runners / local.py: 100%
145 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-22 22:55 +0100
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-22 22:55 +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 if test.status_code == test_proc.returncode:
56 return
57 else:
58 log.error("%s: Test command failed.", shlex.join(test_proc.args))
59 else:
60 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
61 try:
62 s.connect((test.host, test.port))
63 except Exception:
64 log.error("%s:%s: failed to connect.", test.host, test.port)
65 else:
66 return
68 wait = test.backoff_factor * (2 ** (tries - 1))
69 if wait > 0 and tries <= test.retry:
70 time.sleep(wait)
72 raise CommandTestError("Test did not pass")
74 def run_commands(self, part: CommandsPartModel) -> None:
75 for command_config in part.commands:
76 if command_config.run.skip:
77 continue
79 # Capture output if any test is for the output.
80 capture_output = any(isinstance(test, TestOutputModel) for test in command_config.run.test)
82 proc_input = None
83 if stdin_config := command_config.run.stdin:
84 if stdin_config.contents:
85 proc_input = self.render(stdin_config.contents).encode("utf-8")
86 elif stdin_config.template: # source path, but template=True
87 assert stdin_config.source is not None
88 with open(self.tutorial.tutorial_root / stdin_config.source) as stream:
89 stdin_template = stream.read()
90 proc_input = self.render(stdin_template).encode("utf-8")
92 # Run the command and check status code
93 if (
94 command_config.run.stdin
95 and command_config.run.stdin.source
96 and not command_config.run.stdin.template
97 ):
98 with open(self.tutorial.tutorial_root / command_config.run.stdin.source, "rb") as stdin:
99 proc = self.run_shell_command(
100 command_config.command,
101 show_output=command_config.run.show_output,
102 capture_output=capture_output,
103 stdin=stdin,
104 environment=command_config.run.environment,
105 clear_environment=command_config.run.clear_environment,
106 )
107 else:
108 proc = self.run_shell_command(
109 command_config.command,
110 show_output=command_config.run.show_output,
111 capture_output=capture_output,
112 input=proc_input,
113 environment=command_config.run.environment,
114 clear_environment=command_config.run.clear_environment,
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 isinstance(part, PromptModel):
211 if self.interactive:
212 self.run_prompt(part)
213 continue
214 if part.run.skip:
215 continue
217 if part.name: # pragma: no cover
218 part_log.info(part.name)
219 else:
220 part_log.info(f"Running part {part.id}...")
222 if isinstance(part, CommandsPartModel):
223 self.run_commands(part)
224 elif isinstance(part, FilePartModel):
225 self.write_file(part)
226 elif isinstance(part, AlternativeModel):
227 self.run_alternative(part)
228 else: # pragma: no cover
229 raise RuntimeError(f"{part} is not a tutorial part")
231 self.context.update(part.run.update_context)
233 def run(self) -> None:
234 if self.tutorial.configuration.run.temporary_directory:
235 with tempfile.TemporaryDirectory() as tmpdir_name:
236 log.info("Switching to temporary directory: %s", tmpdir_name)
237 self.context["cwd"] = self.context["temp_dir"] = Path(tmpdir_name)
238 self.context["orig_cwd"] = Path.cwd()
240 with chdir(tmpdir_name), cleanup(self):
241 self.run_parts()
242 elif self.tutorial.configuration.run.git_export:
243 with tempfile.TemporaryDirectory() as tmpdir_name:
244 work_dir = git_export(tmpdir_name)
245 log.info("Creating git export at: %s", work_dir)
246 self.context["cwd"] = self.context["export_dir"] = work_dir
247 self.context["orig_cwd"] = Path.cwd()
249 with chdir(work_dir), cleanup(self):
250 self.run_parts()
251 else:
252 with cleanup(self):
253 self.run_parts()