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.
- structured_tutorials/__init__.py +9 -0
- structured_tutorials/cli.py +83 -0
- structured_tutorials/errors.py +40 -0
- structured_tutorials/models/__init__.py +14 -0
- structured_tutorials/models/base.py +97 -0
- structured_tutorials/models/parts.py +227 -0
- structured_tutorials/models/tests.py +40 -0
- structured_tutorials/models/tutorial.py +150 -0
- structured_tutorials/models/validators.py +14 -0
- structured_tutorials/output.py +139 -0
- structured_tutorials/runners/__init__.py +2 -0
- structured_tutorials/runners/base.py +115 -0
- structured_tutorials/runners/local.py +254 -0
- structured_tutorials/sphinx/__init__.py +35 -0
- structured_tutorials/sphinx/directives.py +79 -0
- structured_tutorials/sphinx/utils.py +202 -0
- structured_tutorials/templates/alternative_part.rst.template +5 -0
- structured_tutorials/templates/file_part.rst.template +8 -0
- structured_tutorials/textwrap.py +96 -0
- structured_tutorials/typing.py +15 -0
- structured_tutorials/utils.py +78 -0
- structured_tutorials-0.1.0.dist-info/METADATA +114 -0
- structured_tutorials-0.1.0.dist-info/RECORD +26 -0
- structured_tutorials-0.1.0.dist-info/WHEEL +4 -0
- structured_tutorials-0.1.0.dist-info/entry_points.txt +2 -0
- structured_tutorials-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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]
|