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

1# Copyright (c) 2025 Mathias Ertl 

2# Licensed under the MIT License. See LICENSE file for details. 

3 

4"""Runner that runs a tutorial on the local machine.""" 

5 

6import logging 

7import os 

8import shlex 

9import shutil 

10import socket 

11import subprocess 

12import tempfile 

13import time 

14from pathlib import Path 

15 

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 

21 

22log = logging.getLogger(__name__) 

23part_log = logging.getLogger("part") 

24 

25 

26class LocalTutorialRunner(RunnerBase): 

27 """Runner implementation that runs a tutorial on the local machine.""" 

28 

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 

40 

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}'") 

47 

48 # If an initial delay is configured, wait that long 

49 if test.delay > 0: 

50 time.sleep(test.delay) 

51 

52 tries = 0 

53 while tries <= test.retry: 

54 tries += 1 

55 

56 if isinstance(test, TestCommandModel): 

57 test_proc = self.run_shell_command(test.command, show_output=test.show_output) 

58 

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 

71 

72 wait = test.backoff_factor * (2 ** (tries - 1)) 

73 if wait > 0 and tries <= test.retry: 

74 time.sleep(wait) 

75 

76 raise CommandTestError("Test did not pass") 

77 

78 def run_commands(self, part: CommandsPartModel) -> None: 

79 for command_config in part.commands: 

80 if command_config.run.skip: 

81 continue 

82 

83 # Capture output if any test is for the output. 

84 capture_output = any(isinstance(test, TestOutputModel) for test in command_config.run.test) 

85 

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") 

95 

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 ) 

116 

117 # Update list of cleanup commands 

118 self.cleanup = list(command_config.run.cleanup) + self.cleanup 

119 

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 ) 

126 

127 # Update the context from update_context 

128 self.context.update(command_config.run.update_context) 

129 

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) 

133 

134 # Run test commands 

135 for test_command_config in command_config.run.test: 

136 self.run_test(test_command_config, proc) 

137 

138 def write_file(self, part: FilePartModel) -> None: 

139 """Write a file.""" 

140 raw_destination = self.render(part.destination) 

141 destination = Path(raw_destination) 

142 

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 ) 

150 

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.") 

155 

156 # Create any parent directories 

157 destination.parent.mkdir(parents=True, exist_ok=True) 

158 

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 

163 

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 

170 

171 if part.template: 

172 contents = self.render(template) 

173 else: 

174 contents = template 

175 

176 with open(destination, "w") as destination_stream: 

177 destination_stream.write(contents) 

178 

179 def run_prompt(self, part: PromptModel) -> None: 

180 prompt = self.render(part.prompt).strip() + " " 

181 

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)}).") 

188 

189 if response in ("n", "no") or (response == "" and not part.default): 

190 error = self.render(part.error, response=response) 

191 raise PromptNotConfirmedError(error) 

192 

193 def run_alternative(self, part: AlternativeModel) -> None: 

194 selected = set(self.alternatives) & set(part.alternatives) 

195 

196 # Note: The CLI agent already verifies this - just assert this to be sure. 

197 assert len(selected) <= 1, "More then one part selected." 

198 

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.") 

207 

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}...") 

214 

215 if isinstance(part, PromptModel): 

216 if self.interactive: 

217 self.run_prompt(part) 

218 continue 

219 

220 if part.run.skip: 

221 continue 

222 

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") 

231 

232 self.context.update(part.run.update_context) 

233 

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() 

240 

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() 

249 

250 with chdir(work_dir), cleanup(self): 

251 self.run_parts() 

252 else: 

253 with cleanup(self): 

254 self.run_parts()