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,202 @@
|
|
|
1
|
+
# Copyright (c) 2025 Mathias Ertl
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
"""Utility functions for the sphinx extension."""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from jinja2 import Environment
|
|
13
|
+
from sphinx.application import Sphinx
|
|
14
|
+
from sphinx.config import Config
|
|
15
|
+
from sphinx.errors import ConfigError, ExtensionError
|
|
16
|
+
|
|
17
|
+
from structured_tutorials import templates
|
|
18
|
+
from structured_tutorials.errors import DestinationIsADirectoryError
|
|
19
|
+
from structured_tutorials.models import (
|
|
20
|
+
AlternativeModel,
|
|
21
|
+
CommandsPartModel,
|
|
22
|
+
FilePartModel,
|
|
23
|
+
PromptModel,
|
|
24
|
+
TutorialModel,
|
|
25
|
+
)
|
|
26
|
+
from structured_tutorials.textwrap import wrap_command_filter
|
|
27
|
+
|
|
28
|
+
TEMPLATE_DIR = resources.files(templates)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def validate_configuration(app: Sphinx, config: Config) -> None:
|
|
32
|
+
"""Validate configuration directives, so that we can rely on values later."""
|
|
33
|
+
root = config.tutorial_root
|
|
34
|
+
if not isinstance(root, Path):
|
|
35
|
+
raise ConfigError(f"{root}: Must be of type Path.")
|
|
36
|
+
if not root.is_absolute():
|
|
37
|
+
raise ConfigError(f"{root}: Path must be absolute.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_tutorial_path(tutorial_root: Path, arg: str) -> Path:
|
|
41
|
+
"""Get the full tutorial path and verify existence."""
|
|
42
|
+
tutorial_path = Path(arg)
|
|
43
|
+
if tutorial_path.is_absolute():
|
|
44
|
+
raise ExtensionError(f"{tutorial_path}: Path must not be absolute.")
|
|
45
|
+
|
|
46
|
+
absolute_path = tutorial_root / tutorial_path
|
|
47
|
+
if not absolute_path.exists():
|
|
48
|
+
raise ExtensionError(f"{absolute_path}: File not found.")
|
|
49
|
+
return absolute_path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TutorialWrapper:
|
|
53
|
+
"""Wrapper class for rendering a tutorial.
|
|
54
|
+
|
|
55
|
+
This class exists mainly to wrap the main logic into a separate class that is more easily testable.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, tutorial: TutorialModel, command_text_width: int = 75) -> None:
|
|
59
|
+
self.tutorial = tutorial
|
|
60
|
+
self.next_part = 0
|
|
61
|
+
self.env = Environment(keep_trailing_newline=True)
|
|
62
|
+
self.env.filters["wrap_command"] = wrap_command_filter
|
|
63
|
+
self.context = deepcopy(tutorial.configuration.context)
|
|
64
|
+
self.context.update(deepcopy(tutorial.configuration.doc.context))
|
|
65
|
+
|
|
66
|
+
# settings from sphinx:
|
|
67
|
+
self.command_text_width = command_text_width
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def from_file(cls, path: Path, command_text_width: int = 75) -> "TutorialWrapper":
|
|
71
|
+
"""Factory method for creating a TutorialWrapper from a file."""
|
|
72
|
+
tutorial = TutorialModel.from_file(path)
|
|
73
|
+
return cls(tutorial)
|
|
74
|
+
|
|
75
|
+
def render(self, template: str) -> str:
|
|
76
|
+
return self.env.from_string(template).render(self.context)
|
|
77
|
+
|
|
78
|
+
def render_code_block(self, part: CommandsPartModel) -> str:
|
|
79
|
+
"""Render a CommandsPartModel as a code-block."""
|
|
80
|
+
commands = []
|
|
81
|
+
for command_config in part.commands:
|
|
82
|
+
# Render the prompt
|
|
83
|
+
prompt = self.env.from_string(self.context["prompt_template"]).render(self.context)
|
|
84
|
+
|
|
85
|
+
# Render the command
|
|
86
|
+
if isinstance(command_config.command, str):
|
|
87
|
+
command = self.render(command_config.command)
|
|
88
|
+
else:
|
|
89
|
+
command = shlex.join(self.render(token) for token in command_config.command)
|
|
90
|
+
|
|
91
|
+
# Render output
|
|
92
|
+
output_template = command_config.doc.output.rstrip("\n")
|
|
93
|
+
output = self.env.from_string(output_template).render(self.context)
|
|
94
|
+
|
|
95
|
+
# Finally, render the command
|
|
96
|
+
command_template = """{{ command|wrap_command(prompt, text_width) }}{% if output %}
|
|
97
|
+
{{ output }}{% endif %}"""
|
|
98
|
+
command_context = {
|
|
99
|
+
"prompt": prompt,
|
|
100
|
+
"command": command,
|
|
101
|
+
"output": output,
|
|
102
|
+
"text_width": self.command_text_width,
|
|
103
|
+
}
|
|
104
|
+
rendered_command = self.env.from_string(command_template).render(command_context)
|
|
105
|
+
commands.append(rendered_command)
|
|
106
|
+
|
|
107
|
+
# Update the context from update_context
|
|
108
|
+
self.context.update(command_config.doc.update_context)
|
|
109
|
+
|
|
110
|
+
template = """.. code-block:: console
|
|
111
|
+
|
|
112
|
+
{% for cmd in commands %}{{ cmd|indent(4, first=True) }}
|
|
113
|
+
{% endfor %}"""
|
|
114
|
+
return self.env.from_string(template).render({"commands": commands})
|
|
115
|
+
|
|
116
|
+
def render_file(self, part: FilePartModel) -> str:
|
|
117
|
+
content = part.contents
|
|
118
|
+
if content is None:
|
|
119
|
+
assert part.source is not None # assured by model validation
|
|
120
|
+
with open(self.tutorial.tutorial_root / part.source) as stream:
|
|
121
|
+
content = stream.read()
|
|
122
|
+
|
|
123
|
+
# Only render template if it is configured to be a template.
|
|
124
|
+
if part.template:
|
|
125
|
+
content = self.render(content)
|
|
126
|
+
|
|
127
|
+
# Render the caption (default is the filename)
|
|
128
|
+
if part.doc.caption:
|
|
129
|
+
caption = self.render(part.doc.caption)
|
|
130
|
+
elif part.doc.caption is not False:
|
|
131
|
+
caption = self.render(str(part.destination))
|
|
132
|
+
if caption.endswith(os.path.sep):
|
|
133
|
+
# Model validation already validates that the destination does not look like a directory, if
|
|
134
|
+
# no source is set, but this could be tricked if the destination is a template.
|
|
135
|
+
if not part.source:
|
|
136
|
+
raise DestinationIsADirectoryError(
|
|
137
|
+
f"{caption}: Destination is directory, but no source given to derive filename."
|
|
138
|
+
)
|
|
139
|
+
caption = os.path.join(caption, part.source.name)
|
|
140
|
+
else:
|
|
141
|
+
caption = ""
|
|
142
|
+
|
|
143
|
+
if part.doc.ignore_spelling:
|
|
144
|
+
caption = f":spelling:ignore:`{caption}`"
|
|
145
|
+
|
|
146
|
+
# Read template from resources
|
|
147
|
+
template_str = TEMPLATE_DIR.joinpath("file_part.rst.template").read_text("utf-8")
|
|
148
|
+
|
|
149
|
+
# Render template
|
|
150
|
+
template = self.env.from_string(template_str)
|
|
151
|
+
value = template.render({"part": part, "content": content, "caption": caption})
|
|
152
|
+
return value
|
|
153
|
+
|
|
154
|
+
def render_alternatives(self, part: AlternativeModel) -> str:
|
|
155
|
+
tabs = []
|
|
156
|
+
for key, alternate_part in part.alternatives.items():
|
|
157
|
+
key = self.tutorial.configuration.doc.alternative_names.get(key, key)
|
|
158
|
+
|
|
159
|
+
if isinstance(alternate_part, CommandsPartModel):
|
|
160
|
+
tabs.append((key, self.render_code_block(alternate_part).strip()))
|
|
161
|
+
elif isinstance(alternate_part, FilePartModel):
|
|
162
|
+
tabs.append((key, self.render_file(alternate_part).strip()))
|
|
163
|
+
else: # pragma: no cover
|
|
164
|
+
raise ExtensionError("Alternative found unknown part type.")
|
|
165
|
+
|
|
166
|
+
# Read template from resources
|
|
167
|
+
template_str = TEMPLATE_DIR.joinpath("alternative_part.rst.template").read_text("utf-8")
|
|
168
|
+
|
|
169
|
+
# Render template
|
|
170
|
+
template = self.env.from_string(template_str)
|
|
171
|
+
value = template.render({"part": part, "tabs": tabs})
|
|
172
|
+
return value.strip()
|
|
173
|
+
|
|
174
|
+
def render_part(self, part_id: str | None = None) -> str:
|
|
175
|
+
"""Render the given part of the tutorial."""
|
|
176
|
+
# Find the next part that is not skipped
|
|
177
|
+
for part in self.tutorial.parts[self.next_part :]:
|
|
178
|
+
self.next_part += 1
|
|
179
|
+
|
|
180
|
+
# Ignore prompt models when rendering tutorials.
|
|
181
|
+
if isinstance(part, PromptModel):
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
# If the part is not configured to be skipped for docs, use it.
|
|
185
|
+
if not part.doc.skip:
|
|
186
|
+
if part_id is not None and part.id != part_id:
|
|
187
|
+
raise ExtensionError(f"{part_id}: Part is not the next part (next one is {part.id}).")
|
|
188
|
+
break
|
|
189
|
+
else:
|
|
190
|
+
raise ExtensionError("No more parts left in tutorial.")
|
|
191
|
+
|
|
192
|
+
if isinstance(part, CommandsPartModel):
|
|
193
|
+
text = self.render_code_block(part)
|
|
194
|
+
elif isinstance(part, FilePartModel):
|
|
195
|
+
text = self.render_file(part)
|
|
196
|
+
elif isinstance(part, AlternativeModel):
|
|
197
|
+
text = self.render_alternatives(part)
|
|
198
|
+
else: # pragma: no cover
|
|
199
|
+
raise ExtensionError(f"{part}: Unsupported part type.")
|
|
200
|
+
|
|
201
|
+
self.context.update(part.doc.update_context)
|
|
202
|
+
return text
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
.. code-block::{% if part.doc.language %} {{ part.doc.language }}{% endif %}{% if caption %}
|
|
2
|
+
:caption: {{ caption }}{% endif %}{% if part.doc.linenos or part.doc.lineno_start %}
|
|
3
|
+
:linenos:{% endif %}{% if part.doc.lineno_start %}
|
|
4
|
+
:lineno-start: {{ part.doc.lineno_start }}{% endif %}{% if part.doc.emphasize_lines %}
|
|
5
|
+
:emphasize-lines: {{ part.doc.emphasize_lines }}{% endif %}{% if part.doc.name %}
|
|
6
|
+
:name: {{ part.doc.name }}{% endif %}
|
|
7
|
+
|
|
8
|
+
{{ content|indent() }}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Copyright (c) 2025 Mathias Ertl
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
"""Module for wrapping text width."""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import textwrap
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CommandLineTextWrapper(textwrap.TextWrapper):
|
|
13
|
+
"""Subclass of TextWrapper that "unsplits" a short option and its (supposed) value.
|
|
14
|
+
|
|
15
|
+
This makes sure that a command with many options will not break between short options and their value,
|
|
16
|
+
e.g. for ``docker run -e FOO=foo -e BAR=bar ...``, the text wrapper will never insert a line split between
|
|
17
|
+
``-e`` and its respective option value.
|
|
18
|
+
|
|
19
|
+
Note that the class of course does not know the semantics of the command it renders. A short option
|
|
20
|
+
followed by a value is always considered a reason not to break. For example, for ``docker run ... -d
|
|
21
|
+
image``, the wrapper will never split between ``-d`` and ``image``, despite the latter being unrelated to
|
|
22
|
+
the former.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
26
|
+
super().__init__(*args, **kwargs)
|
|
27
|
+
self.subsequent_indent = "> "
|
|
28
|
+
self.break_on_hyphens = False
|
|
29
|
+
self.break_long_words = False
|
|
30
|
+
self.replace_whitespace = False
|
|
31
|
+
|
|
32
|
+
def _unsplit_optargs(self, chunks: list[str]) -> Iterator[str]:
|
|
33
|
+
unsplit: list[str] = []
|
|
34
|
+
for chunk in chunks:
|
|
35
|
+
if re.match("-[a-z]$", chunk): # chunk appears to be an option
|
|
36
|
+
if unsplit: # previous option was also an optarg, so yield what was there
|
|
37
|
+
yield from unsplit
|
|
38
|
+
unsplit = [chunk]
|
|
39
|
+
elif chunk == " ":
|
|
40
|
+
if unsplit: # this is the whitespace after an option
|
|
41
|
+
unsplit.append(chunk)
|
|
42
|
+
else: # a whitespace not preceded by an option
|
|
43
|
+
yield chunk
|
|
44
|
+
|
|
45
|
+
# The unsplit buffer has two values (short option and space) and this chunk looks like its
|
|
46
|
+
# value, so yield the buffer and this value as split
|
|
47
|
+
elif len(unsplit) == 2 and re.match("[a-zA-Z0-9`]", chunk):
|
|
48
|
+
# unsplit option, whitespace and option value
|
|
49
|
+
unsplit.append(chunk)
|
|
50
|
+
yield "".join(unsplit)
|
|
51
|
+
unsplit = []
|
|
52
|
+
|
|
53
|
+
# There is something in the unsplit buffer, but this chunk does not look like a value (maybe
|
|
54
|
+
# it's a long option?), so we yield tokens from the buffer and then this chunk.
|
|
55
|
+
elif unsplit:
|
|
56
|
+
yield from unsplit
|
|
57
|
+
unsplit = []
|
|
58
|
+
yield chunk
|
|
59
|
+
else:
|
|
60
|
+
yield chunk
|
|
61
|
+
|
|
62
|
+
# yield any remaining chunks
|
|
63
|
+
yield from unsplit
|
|
64
|
+
|
|
65
|
+
def _split(self, text: str) -> list[str]:
|
|
66
|
+
chunks = super()._split(text)
|
|
67
|
+
chunks = list(self._unsplit_optargs(chunks))
|
|
68
|
+
return chunks
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def wrap_command_filter(command: str, prompt: str, text_width: int) -> str:
|
|
72
|
+
"""Filter to wrap a command based on the given text width."""
|
|
73
|
+
lines = []
|
|
74
|
+
split_command_lines = tuple(enumerate(command.split("\\\n"), start=1))
|
|
75
|
+
|
|
76
|
+
# Split paragraphs based on backslash-newline and wrap them separately
|
|
77
|
+
for line_no, command_line in split_command_lines:
|
|
78
|
+
final_line = line_no == len(split_command_lines)
|
|
79
|
+
|
|
80
|
+
# Strip any remaining newline, they are treated as a single space
|
|
81
|
+
command_line = re.sub(r"\s*\n\s*", " ", command_line).strip()
|
|
82
|
+
if not command_line:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
wrapper = CommandLineTextWrapper(width=text_width)
|
|
86
|
+
if line_no == 1:
|
|
87
|
+
wrapper.initial_indent = prompt
|
|
88
|
+
else:
|
|
89
|
+
wrapper.initial_indent = wrapper.subsequent_indent
|
|
90
|
+
|
|
91
|
+
wrapped_command_lines = wrapper.wrap(command_line)
|
|
92
|
+
lines += [
|
|
93
|
+
f"{line} \\" if (i != len(wrapped_command_lines) or not final_line) else line
|
|
94
|
+
for i, line in enumerate(wrapped_command_lines, 1)
|
|
95
|
+
]
|
|
96
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright (c) 2025 Mathias Ertl
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
"""Module that re-exports some type hints."""
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from typing import Self
|
|
8
|
+
except ImportError: # pragma: no cover
|
|
9
|
+
# Note: only for py3.10
|
|
10
|
+
from typing_extensions import Self
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Self",
|
|
15
|
+
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Copyright (c) 2025 Mathias Ertl
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
"""Utility functions."""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import random
|
|
9
|
+
import string
|
|
10
|
+
import subprocess
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from structured_tutorials.errors import PromptNotConfirmedError
|
|
16
|
+
from structured_tutorials.runners.base import RunnerBase
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@contextmanager
|
|
22
|
+
def chdir(dest: str | Path) -> Iterator[Path]:
|
|
23
|
+
"""Context manager to temporarily switch to a different directory."""
|
|
24
|
+
cwd = Path.cwd()
|
|
25
|
+
try:
|
|
26
|
+
os.chdir(dest)
|
|
27
|
+
yield cwd
|
|
28
|
+
finally:
|
|
29
|
+
os.chdir(cwd)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@contextmanager
|
|
33
|
+
def cleanup(runner: RunnerBase) -> Iterator[None]:
|
|
34
|
+
"""Context manager to always run cleanup commands."""
|
|
35
|
+
try:
|
|
36
|
+
yield
|
|
37
|
+
except Exception as ex:
|
|
38
|
+
# Prompt the user to inspect the state if running in interactive mode AND the error is not already a
|
|
39
|
+
# prompt confirmation prompt (in which case we assume the user already inspected the state).
|
|
40
|
+
if not isinstance(ex, PromptNotConfirmedError):
|
|
41
|
+
log.exception(ex)
|
|
42
|
+
|
|
43
|
+
if runner.interactive and not isinstance(ex, PromptNotConfirmedError):
|
|
44
|
+
input(f"""An error occurred while running the tutorial.
|
|
45
|
+
Current working directory is {os.getcwd()}
|
|
46
|
+
|
|
47
|
+
Press Enter to continue... """)
|
|
48
|
+
raise
|
|
49
|
+
finally:
|
|
50
|
+
if runner.cleanup:
|
|
51
|
+
log.info("Running cleanup commands.")
|
|
52
|
+
|
|
53
|
+
for command_config in runner.cleanup:
|
|
54
|
+
runner.run_shell_command(command_config.command, command_config.show_output)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def git_export(destination: str | Path, ref: str = "HEAD") -> Path:
|
|
58
|
+
"""Export the git repository to `django-ca-{ref}/` in the given destination directory.
|
|
59
|
+
|
|
60
|
+
`ref` may be any valid git reference, usually a git tag.
|
|
61
|
+
"""
|
|
62
|
+
# Add a random suffix to the export destination to improve build isolation (e.g. Docker Compose will use
|
|
63
|
+
# that directory name as a name for Docker images/containers).
|
|
64
|
+
random_suffix = "".join(random.choice(string.ascii_lowercase) for i in range(12))
|
|
65
|
+
destination = Path(destination) / f"git-export-{ref}-{random_suffix}"
|
|
66
|
+
|
|
67
|
+
if not destination.exists(): # pragma: no cover # created by caller
|
|
68
|
+
destination.mkdir(parents=True)
|
|
69
|
+
|
|
70
|
+
with subprocess.Popen(["git", "archive", ref], stdout=subprocess.PIPE) as git_archive_cmd:
|
|
71
|
+
with subprocess.Popen(["tar", "-x", "-C", str(destination)], stdin=git_archive_cmd.stdout) as tar:
|
|
72
|
+
# TYPEHINT NOTE: stdout is not None b/c of stdout=subprocess.PIPE
|
|
73
|
+
stdout = git_archive_cmd.stdout
|
|
74
|
+
assert stdout is not None, "stdout not captured."
|
|
75
|
+
stdout.close()
|
|
76
|
+
tar.communicate()
|
|
77
|
+
|
|
78
|
+
return destination
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: structured-tutorials
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Project-URL: Homepage, https://structured-tutorials.readthedocs.io/
|
|
6
|
+
Project-URL: Documentation, https://structured-tutorials.readthedocs.io/
|
|
7
|
+
Project-URL: Source, https://github.com/mathiasertl/structured-tutorials
|
|
8
|
+
Project-URL: Issues, https://github.com/mathiasertl/structured-tutorials/issues
|
|
9
|
+
Project-URL: Changelog, https://structured-tutorials.readthedocs.io/en/latest/changelog.html
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: Sphinx :: Extension
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Documentation :: Sphinx
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: colorama>=0.4.6
|
|
26
|
+
Requires-Dist: jinja2>=3.1.6
|
|
27
|
+
Requires-Dist: pydantic>=2.11.4
|
|
28
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
29
|
+
Requires-Dist: sphinx-inline-tabs>=2025
|
|
30
|
+
Requires-Dist: sphinx>=8.2.0; python_version >= '3.11'
|
|
31
|
+
Requires-Dist: sphinx~=8.1.0; python_version < '3.11'
|
|
32
|
+
Requires-Dist: termcolor>=3.2.0
|
|
33
|
+
Requires-Dist: typing-extensions>=4.6.0; python_version < '3.11'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# structured-tutorials
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+

|
|
40
|
+

|
|
41
|
+

|
|
42
|
+

|
|
43
|
+
|
|
44
|
+
`structured-tutorials` allows you to write tutorials that can be rendered as documentation and run on your
|
|
45
|
+
system to verify correctness.
|
|
46
|
+
|
|
47
|
+
With `structured-tutorials` you to specify steps in a configuration file. A Sphinx plugin allows you to
|
|
48
|
+
render those commands in your project documentation. A command-line tool can load the configuration file and
|
|
49
|
+
run it on your local system.
|
|
50
|
+
|
|
51
|
+
Please see the [official documentation](https://structured-tutorials.readthedocs.io/) for more detailed
|
|
52
|
+
information.
|
|
53
|
+
|
|
54
|
+
## Installation / Setup
|
|
55
|
+
|
|
56
|
+
Install `structured-tutorials`:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
pip install structured-tutorials
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
and configure Sphinx:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
extensions = [
|
|
66
|
+
# ... other extensions
|
|
67
|
+
"structured_tutorials.sphinx",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
# Optional: Root directory for tutorials
|
|
71
|
+
#tutorial_root = DOC_ROOT / "tutorials"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Your first (trivial) tutorial
|
|
75
|
+
|
|
76
|
+
To create your first tutorial, create it in `docs/tutorial.yaml` (or elsewhere, if you configured
|
|
77
|
+
`tutorial_root` in `conf.py`):
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
parts:
|
|
81
|
+
- commands:
|
|
82
|
+
- command: structured-tutorial --help
|
|
83
|
+
doc:
|
|
84
|
+
output: |
|
|
85
|
+
usage: structured-tutorial [-h] path
|
|
86
|
+
...
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Run the tutorial
|
|
90
|
+
|
|
91
|
+
Run the tutorial with:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
$ structured-tutorial docs/tutorials/quickstart/tutorial.yaml
|
|
95
|
+
usage: structured-tutorial [-h] path
|
|
96
|
+
...
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Render tutorial in Sphinx:
|
|
100
|
+
|
|
101
|
+
Configure the tutorial that is being displayed - this will not show any output:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
.. structured-tutorial:: quickstart/tutorial.yaml
|
|
105
|
+
|
|
106
|
+
.. structured-tutorial-part::
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## TODO
|
|
110
|
+
* Run in vagrant
|
|
111
|
+
|
|
112
|
+
# License
|
|
113
|
+
|
|
114
|
+
This project is licensed under the MIT License. See LICENSE file for details.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
structured_tutorials/__init__.py,sha256=W_mhMEZJOxXZYfkORGSmrZNomLjVzxn8gY3H_UCs8X8,299
|
|
2
|
+
structured_tutorials/cli.py,sha256=Q_zvOphVtYujXZHtvYbHqJYWreZlpMgdYJabhkw7de8,2699
|
|
3
|
+
structured_tutorials/errors.py,sha256=heVW-K4eN0cdsxKdk1S1RAZCAb5eqUt9Og0Qoh0bLhM,1172
|
|
4
|
+
structured_tutorials/output.py,sha256=WxANwwxgquyS-x7398x5s0L-PCqHSAWNdFD5XbpG0ng,4356
|
|
5
|
+
structured_tutorials/textwrap.py,sha256=2E64R_caU7BpoxejleHuL2FZxxXP2sbNeSeWu0VWmdg,3929
|
|
6
|
+
structured_tutorials/typing.py,sha256=2iJCysQCUe9VYOS7xLY6rDii08aelFdu54qbNvCUTDE,314
|
|
7
|
+
structured_tutorials/utils.py,sha256=RXy7AM-GRoecDolvg3oKUFl7WSgyneh4G6T5kjfcDpA,2786
|
|
8
|
+
structured_tutorials/models/__init__.py,sha256=DYx6o75z_IS7Pd4TaFBfQzPz5pYtR0FUXklmIz1uADs,421
|
|
9
|
+
structured_tutorials/models/base.py,sha256=B1vCbGYZqPitUciRPk_0sngId3nsgqOa1nJQKP0QiMM,3107
|
|
10
|
+
structured_tutorials/models/parts.py,sha256=irRm2QZi8QH-HHSgWZNAbDFqsPKZM2cBNSE6caHPpMk,8605
|
|
11
|
+
structured_tutorials/models/tests.py,sha256=CM680fZA8epEpGnsX2IMMvimgfl0Xb1-J5jj64Y4SaQ,1370
|
|
12
|
+
structured_tutorials/models/tutorial.py,sha256=W2kBM_tWAV5IbdsGmuGvHrFVFKualc7AeEdTtqEkR3M,5787
|
|
13
|
+
structured_tutorials/models/validators.py,sha256=M6bufqPHqQ6naBupsTly3DjzUqVhZZcVxreOpy14JlA,374
|
|
14
|
+
structured_tutorials/runners/__init__.py,sha256=P8cPEhJfQvGG6v0exX7eSFWB6eJApUDpGtAJUhA6E0I,98
|
|
15
|
+
structured_tutorials/runners/base.py,sha256=qz29GoBIc1VBvOhz7WfZqDzG4Sy2dY2TssdSQSwJCh4,4127
|
|
16
|
+
structured_tutorials/runners/local.py,sha256=OGsqnVSBELdIDuw3rRhIsgPKw8qVxZ9Vk-UJ8I0Wbi0,10429
|
|
17
|
+
structured_tutorials/sphinx/__init__.py,sha256=dDGX0z9OpaW_vwVD8c2ZZoB_zEchkAXw09xa_8jMmLk,1276
|
|
18
|
+
structured_tutorials/sphinx/directives.py,sha256=1-d-n4rPsoKQh3cCdAQp6oY_qWFcTU-u9prSXfpZ6Sw,2604
|
|
19
|
+
structured_tutorials/sphinx/utils.py,sha256=EAsia_0PI8gd-Wjmpy7Ja4BDMhMzMT42nmXq33qYu_U,8062
|
|
20
|
+
structured_tutorials/templates/alternative_part.rst.template,sha256=wSkxUIjcfUuJFAuPpIxZ0wF6aQAdHyjdtD6AV1uyZ4A,96
|
|
21
|
+
structured_tutorials/templates/file_part.rst.template,sha256=oLlYvZmagZMf4QRTxqVe-S0DT0mTVgAj44yV28gUu6Y,483
|
|
22
|
+
structured_tutorials-0.1.0.dist-info/METADATA,sha256=ek0p1BnHQltFrQlOUteSp7WAiyD9zwS0uvA_OOhDrzs,3540
|
|
23
|
+
structured_tutorials-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
24
|
+
structured_tutorials-0.1.0.dist-info/entry_points.txt,sha256=dTFaZHOXee91dHdOrGmaPyvfjQRL62LavX126kDe0nM,70
|
|
25
|
+
structured_tutorials-0.1.0.dist-info/licenses/LICENSE,sha256=pcQmDRzVlV59FDIRkNrBg8jbk2Fl0rUvJGsogBF8lP0,1068
|
|
26
|
+
structured_tutorials-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Mathias Ertl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|