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
« 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.
4"""Base classes for runners."""
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
17from jinja2 import Environment
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
26command_logger = logging.getLogger("command")
29class RunnerBase(abc.ABC):
30 """Base class for runners to provide shared functionality."""
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
42 # Create Jinja2 environment for rendering templates
43 self.env = Environment(keep_trailing_newline=True)
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)
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}
59 self.cleanup: list[CleanupCommandModel] = []
60 self.alternatives = alternatives
61 self.show_command_output = show_command_output
62 self.interactive = interactive
64 def render(self, value: str, **context: Any) -> str:
65 return self.env.from_string(value).render({**self.context, **context})
67 def render_command(self, command: CommandType, **context: Any) -> CommandType:
68 if isinstance(command, str):
69 return self.render(command)
71 return tuple(self.render(token) for token in command)
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
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}'")
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
98 def validate_alternatives(self) -> None:
99 """Validate that for each alternative part, an alternative was selected."""
100 chosen = set(self.alternatives)
102 for part_no, part in enumerate(self.tutorial.parts, start=1):
103 if isinstance(part, AlternativeModel):
104 selected = chosen & set(part.alternatives)
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 )
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
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
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
150 # Render the command (args) as template
151 command = self.render_command(command)
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)
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 )
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()
174 return proc
176 @abc.abstractmethod
177 def run(self) -> None:
178 """Run the tutorial."""