structured-tutorials 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ # Copyright (c) 2025 Mathias Ertl
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ """Collect functions related to output."""
5
+
6
+ import logging
7
+ import logging.config
8
+ import sys
9
+ from typing import Any, ClassVar, Literal
10
+
11
+ from colorama import Fore, Style, just_fix_windows_console
12
+ from termcolor import colored
13
+
14
+ just_fix_windows_console() # needed on Windows
15
+
16
+ LOG_LEVELS = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
17
+
18
+
19
+ def error(text: str) -> None:
20
+ """Output a red/bold line on stderr."""
21
+ print(colored(text, "red", attrs=["bold"]), file=sys.stderr)
22
+
23
+
24
+ class ColorFormatter(logging.Formatter):
25
+ """Base class for color-based formatters."""
26
+
27
+ def __init__(self, *args: Any, no_colors: bool = False, **kwargs: Any) -> None:
28
+ super().__init__(*args, **kwargs)
29
+ # Decide once at formatter creation time
30
+ self.use_colors = sys.stderr.isatty() and no_colors is False
31
+
32
+
33
+ class LevelColorFormatter(ColorFormatter):
34
+ """Formatter that colors the log level."""
35
+
36
+ COLORS: ClassVar[dict[int, str]] = {
37
+ logging.DEBUG: Fore.CYAN,
38
+ logging.INFO: Fore.GREEN,
39
+ logging.WARNING: Fore.YELLOW,
40
+ logging.ERROR: Fore.RED,
41
+ logging.CRITICAL: Fore.MAGENTA,
42
+ }
43
+
44
+ def format(self, record: logging.LogRecord) -> str: # pragma: no cover
45
+ if not self.use_colors:
46
+ return super().format(record)
47
+
48
+ level_name = record.levelname
49
+ color = self.COLORS.get(record.levelno, "")
50
+ record.levelname = f"{color}{level_name.ljust(8)}{Style.RESET_ALL}"
51
+ try:
52
+ return super().format(record)
53
+ finally:
54
+ record.levelname = level_name # restore for other handlers
55
+
56
+
57
+ class BoldFormatter(ColorFormatter):
58
+ """Formatter that outputs all messages in bold, if colors are used."""
59
+
60
+ def format(self, record: logging.LogRecord) -> str: # pragma: no cover
61
+ if not self.use_colors:
62
+ return super().format(record)
63
+
64
+ original = record.msg
65
+ record.msg = f"{Style.BRIGHT}{original}{Style.RESET_ALL}"
66
+ try:
67
+ return super().format(record)
68
+ finally:
69
+ record.msg = original # restore for other handlers
70
+
71
+
72
+ class CommandFormatter(logging.Formatter):
73
+ """Formatter that prepends any log message with a '+ ' (same as `set -x` on a shell)."""
74
+
75
+ def format(self, record: logging.LogRecord) -> str:
76
+ original = record.msg
77
+ record.msg = f"+ {original}"
78
+ try:
79
+ return super().format(record)
80
+ finally:
81
+ record.msg = original # restore for other handlers
82
+
83
+
84
+ def setup_logging(level: LOG_LEVELS, no_colors: bool, show_commands: bool) -> None:
85
+ """Setup logging for the process."""
86
+ command_log_level: LOG_LEVELS = "INFO" if show_commands else "WARNING"
87
+
88
+ config = {
89
+ "version": 1,
90
+ "disable_existing_loggers": False,
91
+ "formatters": {
92
+ "colored": {
93
+ "()": "structured_tutorials.output.LevelColorFormatter",
94
+ "format": "%(levelname)-8s | %(message)s",
95
+ "no_colors": no_colors,
96
+ },
97
+ "bold": {
98
+ "()": "structured_tutorials.output.BoldFormatter",
99
+ "format": "%(message)s",
100
+ "no_colors": no_colors,
101
+ },
102
+ "command": {
103
+ "()": "structured_tutorials.output.CommandFormatter",
104
+ "format": "%(message)s",
105
+ },
106
+ },
107
+ "handlers": {
108
+ "console": {
109
+ "class": "logging.StreamHandler",
110
+ "formatter": "colored",
111
+ },
112
+ "part": {
113
+ "class": "logging.StreamHandler",
114
+ "formatter": "bold",
115
+ },
116
+ "command": {
117
+ "class": "logging.StreamHandler",
118
+ "formatter": "command",
119
+ },
120
+ },
121
+ "loggers": {
122
+ "part": {
123
+ "handlers": ["part"],
124
+ "propagate": False,
125
+ "level": level,
126
+ },
127
+ "command": {
128
+ "handlers": ["command"],
129
+ "propagate": False,
130
+ "level": command_log_level,
131
+ },
132
+ },
133
+ "root": {
134
+ "level": level,
135
+ "handlers": ["console"],
136
+ },
137
+ }
138
+
139
+ logging.config.dictConfig(config)
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2025 Mathias Ertl
2
+ # Licensed under the MIT License. See LICENSE file for details.
@@ -0,0 +1,115 @@
1
+ # Copyright (c) 2025 Mathias Ertl
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ """Base classes for runners."""
5
+
6
+ import abc
7
+ import io
8
+ import logging
9
+ import shlex
10
+ import subprocess
11
+ import sys
12
+ from copy import deepcopy
13
+ from subprocess import CompletedProcess
14
+ from typing import Any
15
+
16
+ from jinja2 import Environment
17
+
18
+ from structured_tutorials.errors import InvalidAlternativesSelectedError
19
+ from structured_tutorials.models import AlternativeModel, TutorialModel
20
+ from structured_tutorials.models.base import CommandType
21
+ from structured_tutorials.models.parts import CleanupCommandModel
22
+
23
+ command_logger = logging.getLogger("command")
24
+
25
+
26
+ class 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."""
@@ -0,0 +1,254 @@
1
+ # Copyright (c) 2025 Mathias Ertl
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ """Runner that runs a tutorial on the local machine."""
5
+
6
+ import logging
7
+ import os
8
+ import shlex
9
+ import shutil
10
+ import socket
11
+ import subprocess
12
+ import tempfile
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from structured_tutorials.errors import CommandOutputTestError, CommandTestError, PromptNotConfirmedError
17
+ from structured_tutorials.models.parts import AlternativeModel, CommandsPartModel, FilePartModel, PromptModel
18
+ from structured_tutorials.models.tests import TestCommandModel, TestOutputModel, TestPortModel
19
+ from structured_tutorials.runners.base import RunnerBase
20
+ from structured_tutorials.utils import chdir, cleanup, git_export
21
+
22
+ log = logging.getLogger(__name__)
23
+ part_log = logging.getLogger("part")
24
+
25
+
26
+ class LocalTutorialRunner(RunnerBase):
27
+ """Runner implementation that runs a tutorial on the local machine."""
28
+
29
+ def run_test(
30
+ self,
31
+ test: TestCommandModel | TestPortModel | TestOutputModel,
32
+ proc: subprocess.CompletedProcess[bytes],
33
+ ) -> None:
34
+ # If the test is for an output stream, we can run it right away (the process has already finished).
35
+ if isinstance(test, TestOutputModel):
36
+ if test.stream == "stderr":
37
+ value = proc.stderr
38
+ else:
39
+ value = proc.stdout
40
+
41
+ if (match := test.regex.search(value)) is not None:
42
+ self.context.update({k: v.decode("utf-8") for k, v in match.groupdict().items()})
43
+ return
44
+ else:
45
+ decoded = value.decode("utf-8")
46
+ raise CommandOutputTestError(f"Process did not have the expected output: '{decoded}'")
47
+
48
+ # If an initial delay is configured, wait that long
49
+ if test.delay > 0:
50
+ time.sleep(test.delay)
51
+
52
+ tries = 0
53
+ while tries <= test.retry:
54
+ tries += 1
55
+
56
+ if isinstance(test, TestCommandModel):
57
+ test_proc = self.run_shell_command(test.command, show_output=test.show_output)
58
+
59
+ if test.status_code == test_proc.returncode:
60
+ return
61
+ else:
62
+ log.error("%s: Test command failed.", shlex.join(test_proc.args))
63
+ else:
64
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
65
+ try:
66
+ s.connect((test.host, test.port))
67
+ except Exception:
68
+ log.error("%s:%s: failed to connect.", test.host, test.port)
69
+ else:
70
+ return
71
+
72
+ wait = test.backoff_factor * (2 ** (tries - 1))
73
+ if wait > 0 and tries <= test.retry:
74
+ time.sleep(wait)
75
+
76
+ raise CommandTestError("Test did not pass")
77
+
78
+ def run_commands(self, part: CommandsPartModel) -> None:
79
+ for command_config in part.commands:
80
+ if command_config.run.skip:
81
+ continue
82
+
83
+ # Capture output if any test is for the output.
84
+ capture_output = any(isinstance(test, TestOutputModel) for test in command_config.run.test)
85
+
86
+ proc_input = None
87
+ if stdin_config := command_config.run.stdin:
88
+ if stdin_config.contents:
89
+ proc_input = self.render(stdin_config.contents).encode("utf-8")
90
+ elif stdin_config.template: # source path, but template=True
91
+ assert stdin_config.source is not None
92
+ with open(self.tutorial.tutorial_root / stdin_config.source) as stream:
93
+ stdin_template = stream.read()
94
+ proc_input = self.render(stdin_template).encode("utf-8")
95
+
96
+ # Run the command and check status code
97
+ if (
98
+ command_config.run.stdin
99
+ and command_config.run.stdin.source
100
+ and not command_config.run.stdin.template
101
+ ):
102
+ with open(self.tutorial.tutorial_root / command_config.run.stdin.source, "rb") as stdin:
103
+ proc = self.run_shell_command(
104
+ command_config.command,
105
+ show_output=command_config.run.show_output,
106
+ capture_output=capture_output,
107
+ stdin=stdin,
108
+ )
109
+ else:
110
+ proc = self.run_shell_command(
111
+ command_config.command,
112
+ show_output=command_config.run.show_output,
113
+ capture_output=capture_output,
114
+ input=proc_input,
115
+ )
116
+
117
+ # Update list of cleanup commands
118
+ self.cleanup = list(command_config.run.cleanup) + self.cleanup
119
+
120
+ # Handle errors in commands
121
+ if proc.returncode != command_config.run.status_code:
122
+ raise RuntimeError(
123
+ f"{command_config.command} failed with return code {proc.returncode} "
124
+ f"(expected: {command_config.run.status_code})."
125
+ )
126
+
127
+ # Update the context from update_context
128
+ self.context.update(command_config.run.update_context)
129
+
130
+ if command_config.run.chdir is not None:
131
+ log.info("Changing working directory to %s.", command_config.run.chdir)
132
+ os.chdir(command_config.run.chdir)
133
+
134
+ # Run test commands
135
+ for test_command_config in command_config.run.test:
136
+ self.run_test(test_command_config, proc)
137
+
138
+ def write_file(self, part: FilePartModel) -> None:
139
+ """Write a file."""
140
+ raw_destination = self.render(part.destination)
141
+ destination = Path(raw_destination)
142
+
143
+ if raw_destination.endswith(os.path.sep):
144
+ # Model validation already validates that the destination does not look like a directory, if no
145
+ # source is set, but this could be tricked if the destination is a template.
146
+ if not part.source:
147
+ raise RuntimeError(
148
+ f"{raw_destination}: Destination is directory, but no source given to derive filename."
149
+ )
150
+
151
+ destination.mkdir(parents=True, exist_ok=True)
152
+ destination = destination / part.source.name
153
+ elif destination.exists():
154
+ raise RuntimeError(f"{destination}: Destination already exists.")
155
+
156
+ # Create any parent directories
157
+ destination.parent.mkdir(parents=True, exist_ok=True)
158
+
159
+ # If template=False and source is set, we just copy the file as is, without ever reading it
160
+ if not part.template and part.source:
161
+ shutil.copy(part.source, destination)
162
+ return
163
+
164
+ if part.source:
165
+ with open(self.tutorial.tutorial_root / part.source) as source_stream:
166
+ template = source_stream.read()
167
+ else:
168
+ assert isinstance(part.contents, str) # assured by model validation
169
+ template = part.contents
170
+
171
+ if part.template:
172
+ contents = self.render(template)
173
+ else:
174
+ contents = template
175
+
176
+ with open(destination, "w") as destination_stream:
177
+ destination_stream.write(contents)
178
+
179
+ def run_prompt(self, part: PromptModel) -> None:
180
+ prompt = self.render(part.prompt).strip() + " "
181
+
182
+ if part.response == "enter":
183
+ input(prompt)
184
+ else: # type == confirm
185
+ valid_inputs = ("n", "no", "yes", "y", "")
186
+ while (response := input(prompt).strip().lower()) not in valid_inputs:
187
+ print(f"Please enter a valid value ({'/'.join(valid_inputs)}).")
188
+
189
+ if response in ("n", "no") or (response == "" and not part.default):
190
+ error = self.render(part.error, response=response)
191
+ raise PromptNotConfirmedError(error)
192
+
193
+ def run_alternative(self, part: AlternativeModel) -> None:
194
+ selected = set(self.alternatives) & set(part.alternatives)
195
+
196
+ # Note: The CLI agent already verifies this - just assert this to be sure.
197
+ assert len(selected) <= 1, "More then one part selected."
198
+
199
+ if selected:
200
+ selected_part = part.alternatives[next(iter(selected))]
201
+ if isinstance(selected_part, CommandsPartModel):
202
+ self.run_commands(selected_part)
203
+ elif isinstance(selected_part, FilePartModel):
204
+ self.write_file(selected_part)
205
+ else: # pragma: no cover
206
+ raise RuntimeError(f"{selected_part} is not supported as alternative.")
207
+
208
+ def run_parts(self) -> None:
209
+ for part in self.tutorial.parts:
210
+ if part.name: # pragma: no cover
211
+ part_log.info(part.name)
212
+ else:
213
+ part_log.info(f"Running part {part.id}...")
214
+
215
+ if isinstance(part, PromptModel):
216
+ if self.interactive:
217
+ self.run_prompt(part)
218
+ continue
219
+
220
+ if part.run.skip:
221
+ continue
222
+
223
+ if isinstance(part, CommandsPartModel):
224
+ self.run_commands(part)
225
+ elif isinstance(part, FilePartModel):
226
+ self.write_file(part)
227
+ elif isinstance(part, AlternativeModel):
228
+ self.run_alternative(part)
229
+ else: # pragma: no cover
230
+ raise RuntimeError(f"{part} is not a tutorial part")
231
+
232
+ self.context.update(part.run.update_context)
233
+
234
+ def run(self) -> None:
235
+ if self.tutorial.configuration.run.temporary_directory:
236
+ with tempfile.TemporaryDirectory() as tmpdir_name:
237
+ log.info("Switching to temporary directory: %s", tmpdir_name)
238
+ self.context["cwd"] = self.context["temp_dir"] = Path(tmpdir_name)
239
+ self.context["orig_cwd"] = Path.cwd()
240
+
241
+ with chdir(tmpdir_name), cleanup(self):
242
+ self.run_parts()
243
+ elif self.tutorial.configuration.run.git_export:
244
+ with tempfile.TemporaryDirectory() as tmpdir_name:
245
+ work_dir = git_export(tmpdir_name)
246
+ log.info("Creating git export at: %s", work_dir)
247
+ self.context["cwd"] = self.context["export_dir"] = work_dir
248
+ self.context["orig_cwd"] = Path.cwd()
249
+
250
+ with chdir(work_dir), cleanup(self):
251
+ self.run_parts()
252
+ else:
253
+ with cleanup(self):
254
+ self.run_parts()
@@ -0,0 +1,35 @@
1
+ """Sphinx extension for rendering tutorials."""
2
+
3
+ from pathlib import Path
4
+
5
+ from sphinx.application import Sphinx
6
+ from sphinx.errors import ExtensionError
7
+ from sphinx.util.typing import ExtensionMetadata
8
+
9
+ from structured_tutorials import __version__
10
+ from structured_tutorials.sphinx.directives import PartDirective, TutorialDirective
11
+ from structured_tutorials.sphinx.utils import validate_configuration
12
+
13
+
14
+ def setup(app: Sphinx) -> ExtensionMetadata:
15
+ """Sphinx setup function."""
16
+ # Add dependency on other extension:
17
+ # app.setup_extension("sphinx.ext.autodoc")
18
+ app.connect("config-inited", validate_configuration)
19
+ app.add_config_value("tutorial_root", Path(app.srcdir), "env", types=[Path])
20
+ app.add_config_value("structured_tutorial_command_text_width", 75, "env", types=[int])
21
+
22
+ app.add_directive("structured-tutorial", TutorialDirective)
23
+ app.add_directive("structured-tutorial-part", PartDirective)
24
+
25
+ try:
26
+ app.setup_extension("sphinx_inline_tabs")
27
+ except Exception as exc: # pragma: no cover
28
+ raise ExtensionError("structured_tutorials requires sphinx_inline_tabs") from exc
29
+
30
+ # return metadata
31
+ return {
32
+ "version": __version__,
33
+ "parallel_read_safe": True,
34
+ "parallel_write_safe": True,
35
+ }
@@ -0,0 +1,79 @@
1
+ # Copyright (c) 2025 Mathias Ertl
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ """Directives for Sphinx."""
5
+
6
+ from typing import TYPE_CHECKING
7
+
8
+ from docutils.nodes import Node, paragraph
9
+ from docutils.parsers.rst.states import RSTState
10
+ from docutils.statemachine import StringList
11
+ from sphinx.environment import BuildEnvironment
12
+ from sphinx.util.docutils import SphinxDirective
13
+
14
+ from structured_tutorials.sphinx.utils import TutorialWrapper, get_tutorial_path
15
+
16
+ if TYPE_CHECKING:
17
+ from sphinx.environment import _CurrentDocument
18
+
19
+
20
+ class CurrentDocumentMixin:
21
+ """Mixin adding the current document property and context."""
22
+
23
+ if TYPE_CHECKING:
24
+ env: BuildEnvironment
25
+
26
+ # NOTE: sphinx 8.2.0 introduced "current_document", temp_data is deprecated and kept only for
27
+ # backwards compatability: https://github.com/sphinx-doc/sphinx/pull/13151
28
+ @property
29
+ def current_document(self) -> "_CurrentDocument": # pragma: no cover
30
+ if hasattr(self.env, "current_document"):
31
+ return self.env.current_document
32
+ else:
33
+ return self.env.temp_data
34
+
35
+
36
+ class TutorialDirective(CurrentDocumentMixin, SphinxDirective):
37
+ """Directive to specify the currently rendered tutorial."""
38
+
39
+ has_content = False
40
+ required_arguments = 1
41
+ optional_arguments = 0
42
+
43
+ def run(self) -> list[Node]:
44
+ tutorial_arg = self.arguments[0].strip()
45
+
46
+ command_text_width: int = self.config.structured_tutorial_command_text_width
47
+ tutorial_path = get_tutorial_path(self.config.tutorial_root, tutorial_arg)
48
+ self.current_document["tutorial"] = TutorialWrapper.from_file(
49
+ tutorial_path, command_text_width=command_text_width
50
+ )
51
+
52
+ # NOTE: `highlighting` directive returns a custom Element for unknown reasons
53
+ return []
54
+
55
+
56
+ class PartDirective(CurrentDocumentMixin, SphinxDirective):
57
+ """Directive to show a tutorial part."""
58
+
59
+ required_arguments = 0
60
+ optional_arguments = 1
61
+
62
+ def run(self) -> list[paragraph]:
63
+ # Get the named ID if set.
64
+ part_id = None
65
+ if self.arguments:
66
+ part_id = self.arguments[0].strip()
67
+
68
+ # Render text
69
+ tutorial_wrapper: TutorialWrapper = self.current_document["tutorial"]
70
+ text = tutorial_wrapper.render_part(part_id=part_id)
71
+
72
+ source, _lineno = self.get_source_info()
73
+
74
+ # Create sphinx node
75
+ node = paragraph()
76
+ paragraph.source = source
77
+ state: RSTState = self.state
78
+ state.nested_parse(StringList(text.splitlines(), source=source), 0, node)
79
+ return [node]