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

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 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 self.test_output(proc, test) 

37 return 

38 

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

40 if test.delay > 0: 

41 time.sleep(test.delay) 

42 

43 tries = 0 

44 while tries <= test.retry: 

45 tries += 1 

46 

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 ) 

54 

55 # Update environment regardless of success of command 

56 self.environment.update({k: self.render(v) for k, v in test.update_environment.items()}) 

57 

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 

70 

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

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

73 time.sleep(wait) 

74 

75 raise CommandTestError("Test did not pass") 

76 

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

78 for command_config in part.commands: 

79 if command_config.run.skip: 

80 continue 

81 

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

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

84 

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

94 

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 ) 

119 

120 # Update list of cleanup commands 

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

122 

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 ) 

129 

130 # Update the context from update_context 

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

132 

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) 

138 

139 # Run test commands 

140 for test_command_config in command_config.run.test: 

141 self.run_test(test_command_config, proc) 

142 

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 ) 

147 

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

149 """Write a file.""" 

150 raw_destination = self.render(part.destination) 

151 destination = Path(raw_destination) 

152 

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 ) 

160 

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

165 

166 # Create any parent directories 

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

168 

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 

173 

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 

180 

181 if part.template: 

182 contents = self.render(template) 

183 else: 

184 contents = template 

185 

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

187 destination_stream.write(contents) 

188 

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

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

191 

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

198 

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

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

201 raise PromptNotConfirmedError(error) 

202 

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

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

205 

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

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

208 

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

217 

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 

226 

227 if part.name: # pragma: no cover 

228 part_log.info(part.name) 

229 else: 

230 part_log.info(f"Running part {part.id}...") 

231 

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

240 

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

242 

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

249 

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

258 

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

260 self.run_parts() 

261 else: 

262 with cleanup(self): 

263 self.run_parts()