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

98 statements  

« 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. 

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 self.context = deepcopy(tutorial.configuration.context) 

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

43 if context: 

44 self.context.update(context) 

45 

46 self.env = Environment(keep_trailing_newline=True) 

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

48 self.alternatives = alternatives 

49 self.show_command_output = show_command_output 

50 self.interactive = interactive 

51 

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

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

54 

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

56 if isinstance(command, str): 

57 return self.render(command) 

58 

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

60 

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

62 if test.stream == "stderr": 

63 value = proc.stderr 

64 else: 

65 value = proc.stdout 

66 

67 if test.regex: 

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

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

70 else: 

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

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

73 

74 # Test for line/character count of output 

75 if test.line_count: 

76 try: 

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

78 except ValueError as ex: 

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

80 if test.character_count: 

81 try: 

82 check_count(value, test.character_count) 

83 except ValueError as ex: 

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

85 

86 def validate_alternatives(self) -> None: 

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

88 chosen = set(self.alternatives) 

89 

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

91 if isinstance(part, AlternativeModel): 

92 selected = chosen & set(part.alternatives) 

93 

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

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

96 elif len(selected) != 1: 

97 raise InvalidAlternativesSelectedError( 

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

99 ) 

100 

101 def run_shell_command( 

102 self, 

103 command: CommandType, 

104 show_output: bool, 

105 capture_output: bool = False, 

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

107 input: bytes | None = None, 

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

109 clear_environment: bool = False, 

110 ) -> CompletedProcess[bytes]: 

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

112 if show_output: 

113 show_output = self.show_command_output 

114 

115 # Configure stdout/stderr streams 

116 if capture_output: 

117 stdout: int | None = subprocess.PIPE 

118 stderr: int | None = subprocess.PIPE 

119 elif show_output: 

120 stdout = stderr = None 

121 else: 

122 stdout = stderr = subprocess.DEVNULL 

123 

124 # Configure environment 

125 if environment: 

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

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

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

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

130 environment = {} 

131 if clear_environment: 

132 env = environment 

133 elif environment: 

134 env = {**os.environ, **environment} 

135 else: 

136 env = None 

137 

138 # Render the command (args) as template 

139 command = self.render_command(command) 

140 

141 shell = True 

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

143 if isinstance(command, tuple): 

144 shell = False 

145 logged_command = shlex.join(logged_command) 

146 

147 command_logger.info(logged_command) 

148 proc = subprocess.run( 

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

150 ) 

151 

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

153 # separately at the end. 

154 if capture_output and show_output: 

155 print("--- stdout ---") 

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

157 sys.stdout.buffer.flush() 

158 print("--- stderr ---") 

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

160 sys.stdout.buffer.flush() 

161 

162 return proc 

163 

164 @abc.abstractmethod 

165 def run(self) -> None: 

166 """Run the tutorial."""