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
« 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.
4"""Base classes for runners."""
6import abc
7import io
8import logging
9import shlex
10import subprocess
11import sys
12from copy import deepcopy
13from subprocess import CompletedProcess
14from typing import Any
16from jinja2 import Environment
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
23command_logger = logging.getLogger("command")
26class RunnerBase(abc.ABC):
27 """Base class for runners to provide shared functionality."""
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
45 def render(self, value: str, **context: Any) -> str:
46 return self.env.from_string(value).render({**self.context, **context})
48 def render_command(self, command: CommandType, **context: Any) -> CommandType:
49 if isinstance(command, str):
50 return self.render(command)
52 return tuple(self.render(token) for token in command)
54 def validate_alternatives(self) -> None:
55 """Validate that for each alternative part, an alternative was selected."""
56 chosen = set(self.alternatives)
58 for part_no, part in enumerate(self.tutorial.parts, start=1):
59 if isinstance(part, AlternativeModel):
60 selected = chosen & set(part.alternatives)
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 )
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
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
89 # Render the command (args) as template
90 command = self.render_command(command)
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)
98 command_logger.info(logged_command)
99 proc = subprocess.run(command, shell=shell, stdin=stdin, input=input, stdout=stdout, stderr=stderr)
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()
111 return proc
113 @abc.abstractmethod
114 def run(self) -> None:
115 """Run the tutorial."""