Coverage for structured_tutorials / runners / base.py: 100%

103 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"""Base classes for runners.""" 

5 

6import abc 

7import io 

8import logging 

9import os 

10import shlex 

11import subprocess 

12import sys 

13from copy import deepcopy 

14from subprocess import CompletedProcess 

15from typing import Any 

16 

17from jinja2 import Environment 

18 

19from structured_tutorials.errors import CommandOutputTestError, InvalidAlternativesSelectedError 

20from structured_tutorials.models import AlternativeModel, TutorialModel 

21from structured_tutorials.models.base import CommandType 

22from structured_tutorials.models.parts import CleanupCommandModel 

23from structured_tutorials.models.tests import TestOutputModel 

24from structured_tutorials.utils import check_count 

25 

26command_logger = logging.getLogger("command") 

27 

28 

29class RunnerBase(abc.ABC): 

30 """Base class for runners to provide shared functionality.""" 

31 

32 def __init__( 

33 self, 

34 tutorial: TutorialModel, 

35 alternatives: tuple[str, ...] = (), 

36 show_command_output: bool = True, 

37 interactive: bool = True, 

38 context: dict[str, Any] | None = None, 

39 ): 

40 self.tutorial = tutorial 

41 

42 # Create Jinja2 environment for rendering templates 

43 self.env = Environment(keep_trailing_newline=True) 

44 

45 # Compute initial context 

46 self.context = deepcopy(tutorial.configuration.context) 

47 self.context.update(deepcopy(tutorial.configuration.run.context)) 

48 if context: 

49 self.context.update(context) 

50 

51 # Set up the environment for commands 

52 if tutorial.configuration.run.clear_environment: 

53 self.environment = {} 

54 else: 

55 self.environment = os.environ.copy() 

56 self.environment.update(tutorial.configuration.run.environment) 

57 self.environment = {k: self.render(v) for k, v in self.environment.items() if v is not None} 

58 

59 self.cleanup: list[CleanupCommandModel] = [] 

60 self.alternatives = alternatives 

61 self.show_command_output = show_command_output 

62 self.interactive = interactive 

63 

64 def render(self, value: str, **context: Any) -> str: 

65 return self.env.from_string(value).render({**self.context, **context}) 

66 

67 def render_command(self, command: CommandType, **context: Any) -> CommandType: 

68 if isinstance(command, str): 

69 return self.render(command) 

70 

71 return tuple(self.render(token) for token in command) 

72 

73 def test_output(self, proc: subprocess.CompletedProcess[bytes], test: TestOutputModel) -> None: 

74 if test.stream == "stderr": 

75 value = proc.stderr 

76 else: 

77 value = proc.stdout 

78 

79 if test.regex: 

80 if (match := test.regex.search(value)) is not None: 

81 self.context.update({k: v.decode("utf-8") for k, v in match.groupdict().items()}) 

82 else: 

83 decoded = value.decode("utf-8") 

84 raise CommandOutputTestError(f"Process did not have the expected output: '{decoded}'") 

85 

86 # Test for line/character count of output 

87 if test.line_count: 

88 try: 

89 check_count(value.splitlines(), test.line_count) 

90 except ValueError as ex: 

91 raise CommandOutputTestError(f"Line count error: {ex}") from ex 

92 if test.character_count: 

93 try: 

94 check_count(value, test.character_count) 

95 except ValueError as ex: 

96 raise CommandOutputTestError(f"Character count error: {ex}") from ex 

97 

98 def validate_alternatives(self) -> None: 

99 """Validate that for each alternative part, an alternative was selected.""" 

100 chosen = set(self.alternatives) 

101 

102 for part_no, part in enumerate(self.tutorial.parts, start=1): 

103 if isinstance(part, AlternativeModel): 

104 selected = chosen & set(part.alternatives) 

105 

106 if part.required and len(selected) == 0: 

107 raise InvalidAlternativesSelectedError(f"Part {part_no}: No alternative selected.") 

108 elif len(selected) != 1: 

109 raise InvalidAlternativesSelectedError( 

110 f"Part {part_no}: More then one alternative selected: {selected}" 

111 ) 

112 

113 def run_shell_command( 

114 self, 

115 command: CommandType, 

116 show_output: bool, 

117 capture_output: bool = False, 

118 stdin: int | io.BufferedReader | None = None, 

119 input: bytes | None = None, 

120 environment: dict[str, Any] | None = None, 

121 clear_environment: bool = False, 

122 ) -> CompletedProcess[bytes]: 

123 # Only show output if runner itself is not configured to hide all output 

124 if show_output: 

125 show_output = self.show_command_output 

126 

127 # Configure stdout/stderr streams 

128 if capture_output: 

129 stdout: int | None = subprocess.PIPE 

130 stderr: int | None = subprocess.PIPE 

131 elif show_output: 

132 stdout = stderr = None 

133 else: 

134 stdout = stderr = subprocess.DEVNULL 

135 

136 # Configure environment 

137 if environment: 

138 # If environment is passed, we render variables as templates 

139 environment = {k: self.render(v) for k, v in environment.items()} 

140 elif environment is None: # pragma: no cover # dict is always passed directly 

141 # just to simplify the next branch -> environment is always a dict 

142 environment = {} 

143 if clear_environment: 

144 env = environment 

145 elif environment: 

146 env = {**self.environment, **environment} 

147 else: 

148 env = self.environment 

149 

150 # Render the command (args) as template 

151 command = self.render_command(command) 

152 

153 shell = True 

154 logged_command = command # The command string we pass to the logger 

155 if isinstance(command, tuple): 

156 shell = False 

157 logged_command = shlex.join(logged_command) 

158 

159 command_logger.info(logged_command) 

160 proc = subprocess.run( 

161 command, shell=shell, stdin=stdin, input=input, stdout=stdout, stderr=stderr, env=env 

162 ) 

163 

164 # If we want to show the output and capture it at the same time, we need can only show the streams 

165 # separately at the end. 

166 if capture_output and show_output: 

167 print("--- stdout ---") 

168 sys.stdout.buffer.write(proc.stdout + b"\n") 

169 sys.stdout.buffer.flush() 

170 print("--- stderr ---") 

171 sys.stdout.buffer.write(proc.stderr + b"\n") 

172 sys.stdout.buffer.flush() 

173 

174 return proc 

175 

176 @abc.abstractmethod 

177 def run(self) -> None: 

178 """Run the tutorial."""