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,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,5 @@
1
+ {% for title, contents in tabs %}
2
+ .. tab:: {{ title }}
3
+
4
+ {{ contents|indent() }}
5
+ {% endfor %}
@@ -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
+ ![image](https://github.com/mathiasertl/structured-tutorials/workflows/Tests/badge.svg)
39
+ ![image](https://img.shields.io/pypi/v/structured-tutorials.svg)
40
+ ![image](https://img.shields.io/pypi/dm/structured-tutorials.svg)
41
+ ![image](https://img.shields.io/pypi/pyversions/structured-tutorials.svg)
42
+ ![image](https://img.shields.io/github/license/mathiasertl/structured-tutorials)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ structured-tutorial = structured_tutorials.cli:main
@@ -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.