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
« 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.
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
41 self.context = deepcopy(tutorial.configuration.context)
42 self.context.update(deepcopy(tutorial.configuration.run.context))
43 if context:
44 self.context.update(context)
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
52 def render(self, value: str, **context: Any) -> str:
53 return self.env.from_string(value).render({**self.context, **context})
55 def render_command(self, command: CommandType, **context: Any) -> CommandType:
56 if isinstance(command, str):
57 return self.render(command)
59 return tuple(self.render(token) for token in command)
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
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}'")
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
86 def validate_alternatives(self) -> None:
87 """Validate that for each alternative part, an alternative was selected."""
88 chosen = set(self.alternatives)
90 for part_no, part in enumerate(self.tutorial.parts, start=1):
91 if isinstance(part, AlternativeModel):
92 selected = chosen & set(part.alternatives)
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 )
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
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
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
138 # Render the command (args) as template
139 command = self.render_command(command)
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)
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 )
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()
162 return proc
164 @abc.abstractmethod
165 def run(self) -> None:
166 """Run the tutorial."""