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

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

5 

6import abc 

7import io 

8import logging 

9import shlex 

10import subprocess 

11import sys 

12from copy import deepcopy 

13from subprocess import CompletedProcess 

14from typing import Any 

15 

16from jinja2 import Environment 

17 

18from structured_tutorials.errors import InvalidAlternativesSelectedError 

19from structured_tutorials.models import AlternativeModel, TutorialModel 

20from structured_tutorials.models.base import CommandType 

21from structured_tutorials.models.parts import CleanupCommandModel 

22 

23command_logger = logging.getLogger("command") 

24 

25 

26class RunnerBase(abc.ABC): 

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

28 

29 def __init__( 

30 self, 

31 tutorial: TutorialModel, 

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

33 show_command_output: bool = True, 

34 interactive: bool = True, 

35 ): 

36 self.tutorial = tutorial 

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

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

39 self.env = Environment(keep_trailing_newline=True) 

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

41 self.alternatives = alternatives 

42 self.show_command_output = show_command_output 

43 self.interactive = interactive 

44 

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

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

47 

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

49 if isinstance(command, str): 

50 return self.render(command) 

51 

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

53 

54 def validate_alternatives(self) -> None: 

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

56 chosen = set(self.alternatives) 

57 

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

59 if isinstance(part, AlternativeModel): 

60 selected = chosen & set(part.alternatives) 

61 

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

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

64 elif len(selected) != 1: 

65 raise InvalidAlternativesSelectedError( 

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

67 ) 

68 

69 def run_shell_command( 

70 self, 

71 command: CommandType, 

72 show_output: bool, 

73 capture_output: bool = False, 

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

75 input: bytes | None = None, 

76 ) -> CompletedProcess[bytes]: 

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

78 if show_output: 

79 show_output = self.show_command_output 

80 

81 if capture_output: 

82 stdout: int | None = subprocess.PIPE 

83 stderr: int | None = subprocess.PIPE 

84 elif show_output: 

85 stdout = stderr = None 

86 else: 

87 stdout = stderr = subprocess.DEVNULL 

88 

89 # Render the command (args) as template 

90 command = self.render_command(command) 

91 

92 shell = True 

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

94 if isinstance(command, tuple): 

95 shell = False 

96 logged_command = shlex.join(logged_command) 

97 

98 command_logger.info(logged_command) 

99 proc = subprocess.run(command, shell=shell, stdin=stdin, input=input, stdout=stdout, stderr=stderr) 

100 

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

102 # separately at the end. 

103 if capture_output and show_output: 

104 print("--- stdout ---") 

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

106 sys.stdout.buffer.flush() 

107 print("--- stderr ---") 

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

109 sys.stdout.buffer.flush() 

110 

111 return proc 

112 

113 @abc.abstractmethod 

114 def run(self) -> None: 

115 """Run the tutorial."""