Skip to content

Commit f3696e4

Browse files
committed
implement FileParts
1 parent 100b8f7 commit f3696e4

File tree

8 files changed

+222
-8
lines changed

8 files changed

+222
-8
lines changed

docs/quickstart.rst

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,30 @@ However, when running, you will just get:
7979
8080
user@host:~$ structured-tutorial docs/tutorials/templates/tutorial.yaml
8181
real data
82+
83+
***************************
84+
Tutorials can contain files
85+
***************************
86+
87+
Tutorials can contain files that are also rendered as templates.
88+
89+
The following example tutorial could be used to instruct the user to create a JSON file in
90+
``/tmp/example.json`` and then call ``python`` to verify the syntax.
91+
92+
Using this YAML file:
93+
94+
.. literalinclude:: /tutorials/simple-file/tutorial.yaml
95+
:caption: docs/tutorials/simple-file/tutorial.yaml
96+
:language: yaml
97+
98+
you could render a Tutorial like this:
99+
100+
.. structured-tutorial:: simple-file/tutorial.yaml
101+
102+
Create a configuration file with the following contents:
103+
104+
.. structured-tutorial-part::
105+
106+
and make sure it is valid JSON:
107+
108+
.. structured-tutorial-part::

docs/tutorials/exit_code/tutorial.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ parts:
22
- commands:
33
- command: "true"
44
- command: "true"
5-
status_code: 0
5+
run:
6+
status_code: 0
67
# Do not fail the tutorial if this command returns exit code 1.
78
- command: "false"
89
run:
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
configuration:
2+
doc:
3+
context:
4+
file_contents: "{\"key\": \"value\"}"
5+
file_path: /tmp/example.json
6+
parts:
7+
# Part one: Create configuration file
8+
- contents: |
9+
{{ file_contents }}
10+
destination: "{{ file_path }}"
11+
doc:
12+
language: json
13+
# Part two: make sure the contents are right (as a demo)
14+
- commands:
15+
- command: python -m json.tool --compact {{ file_path }}
16+
doc:
17+
output: "{{ file_contents }}"

structured_tutorials/models.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from pathlib import Path
44
from typing import Annotated, Any, Literal, Self
55

6-
from pydantic import BaseModel, Field, field_validator, model_validator
6+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
77
from pydantic_core.core_schema import ValidationInfo
88
from yaml import safe_load
99

@@ -19,12 +19,16 @@ def default_cwd_factory(data: dict[str, Any]) -> Path:
1919
class CommandBaseModel(BaseModel):
2020
"""Base model for commands."""
2121

22+
model_config = ConfigDict(extra="forbid")
23+
2224
status_code: Annotated[int, Field(ge=0, le=255)] = 0
2325

2426

2527
class TestSpecificationMixin:
2628
"""Mixin for specifying tests."""
2729

30+
model_config = ConfigDict(extra="forbid")
31+
2832
delay: Annotated[float, Field(ge=0)] = 0
2933
retry: PositiveInt = 0
3034
backoff_factor: PositiveFloat = 0 # {backoff factor} * (2 ** ({number of previous retries}))
@@ -33,25 +37,33 @@ class TestSpecificationMixin:
3337
class CleanupCommandModel(CommandBaseModel):
3438
"""Model for cleanup commands."""
3539

40+
model_config = ConfigDict(extra="forbid")
41+
3642
command: str
3743

3844

3945
class TestCommandModel(TestSpecificationMixin, CommandBaseModel):
4046
"""Model for a test command for a normal command."""
4147

48+
model_config = ConfigDict(extra="forbid")
49+
4250
command: str
4351

4452

4553
class TestPortModel(TestSpecificationMixin, BaseModel):
4654
"""Model for testing connectivity after a command is run."""
4755

56+
model_config = ConfigDict(extra="forbid")
57+
4858
host: str
4959
port: Annotated[int, Field(ge=0, le=65535)]
5060

5161

5262
class CommandRuntimeConfigurationModel(CommandBaseModel):
5363
"""Model for runtime configuration when running a single command."""
5464

65+
model_config = ConfigDict(extra="forbid")
66+
5567
update_context: dict[str, Any] = Field(default_factory=dict)
5668
cleanup: tuple[CleanupCommandModel, ...] = tuple()
5769
test: tuple[TestCommandModel | TestPortModel, ...] = tuple()
@@ -60,13 +72,17 @@ class CommandRuntimeConfigurationModel(CommandBaseModel):
6072
class CommandDocumentationConfigurationModel(BaseModel):
6173
"""Model for documenting a single command."""
6274

75+
model_config = ConfigDict(extra="forbid")
76+
6377
output: str = ""
6478
update_context: dict[str, Any] = Field(default_factory=dict)
6579

6680

6781
class CommandModel(BaseModel):
6882
"""Model for a single command."""
6983

84+
model_config = ConfigDict(extra="forbid")
85+
7086
command: str
7187
run: CommandRuntimeConfigurationModel = CommandRuntimeConfigurationModel()
7288
doc: CommandDocumentationConfigurationModel = CommandDocumentationConfigurationModel()
@@ -75,19 +91,35 @@ class CommandModel(BaseModel):
7591
class CommandsPartModel(BaseModel):
7692
"""Model for a set of commands."""
7793

94+
model_config = ConfigDict(extra="forbid")
95+
7896
type: Literal["commands"] = "commands"
7997
commands: tuple[CommandModel, ...]
8098

8199

100+
class FileDocumentationConfigurationModel(BaseModel):
101+
# sphinx options:
102+
language: str = ""
103+
caption: str | Literal[False] = ""
104+
linenos: bool = False
105+
lineno_start: PositiveInt | Literal[False] = False
106+
emphasize_lines: str = ""
107+
name: str = ""
108+
109+
82110
class FilePartModel(BaseModel):
83111
"""Model for a file to be copied."""
84112

113+
model_config = ConfigDict(extra="forbid")
114+
85115
type: Literal["file"] = "file"
86116
contents: str | None = None
87117
source: Path | None = None
88118
destination: Path
89119
template: bool = True
90120

121+
doc: FileDocumentationConfigurationModel = FileDocumentationConfigurationModel()
122+
91123
@field_validator("source", mode="after")
92124
@classmethod
93125
def validate_source(cls, value: Path) -> Path:
@@ -99,12 +131,16 @@ def validate_source(cls, value: Path) -> Path:
99131
def validate_contents_or_source(self) -> Self:
100132
if self.contents is None and self.source is None:
101133
raise ValueError("Either contents or source is required.")
134+
if self.contents is not None and self.source is not None:
135+
raise ValueError("Only one of contents or source is allowed.")
102136
return self
103137

104138

105139
class RuntimeConfigurationModel(BaseModel):
106140
"""Model for configuration at runtime."""
107141

142+
model_config = ConfigDict(extra="forbid")
143+
108144
context: dict[str, Any] = Field(default_factory=dict)
109145

110146
@model_validator(mode="after")
@@ -117,6 +153,8 @@ def set_default_context(self) -> Self:
117153
class DocumentationConfigurationModel(BaseModel):
118154
"""Model for configuration of the documentation."""
119155

156+
model_config = ConfigDict(extra="forbid")
157+
120158
context: dict[str, Any] = Field(default_factory=dict)
121159

122160
@model_validator(mode="after")
@@ -136,13 +174,17 @@ def set_default_context(self) -> Self:
136174
class ConfigurationModel(BaseModel):
137175
"""Model for the initial configuration of a tutorial."""
138176

177+
model_config = ConfigDict(extra="forbid")
178+
139179
run: RuntimeConfigurationModel = RuntimeConfigurationModel()
140180
doc: DocumentationConfigurationModel = DocumentationConfigurationModel()
141181

142182

143183
class TutorialModel(BaseModel):
144184
"""Model representing the entire tutorial."""
145185

186+
model_config = ConfigDict(extra="forbid")
187+
146188
path: Path # absolute path
147189
cwd: Path = Field(default_factory=default_cwd_factory) # absolute path (input: relative to path)
148190
parts: tuple[CommandsPartModel | FilePartModel, ...]

structured_tutorials/runners/local.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Runner that runs a tutorial on the local machine."""
22

3+
import shutil
34
import socket
45
import subprocess
56
import time
@@ -10,6 +11,7 @@
1011
from structured_tutorials.models import (
1112
CleanupCommandModel,
1213
CommandsPartModel,
14+
FilePartModel,
1315
TestCommandModel,
1416
TestPortModel,
1517
TutorialModel,
@@ -58,7 +60,7 @@ def run_test(self, test: TestCommandModel | TestPortModel) -> None:
5860

5961
raise RuntimeError("Test did not pass")
6062

61-
def run_command(self, part: CommandsPartModel) -> None:
63+
def run_commands(self, part: CommandsPartModel) -> None:
6264
for command_config in part.commands:
6365
# Render the command
6466
command = self.render(command_config.command)
@@ -83,13 +85,46 @@ def run_command(self, part: CommandsPartModel) -> None:
8385
for test_command_config in command_config.run.test:
8486
self.run_test(test_command_config)
8587

88+
def write_file(self, part: FilePartModel) -> None:
89+
"""Write a file."""
90+
if part.destination.is_dir() and not part.source:
91+
raise RuntimeError("Destination is directory, but no source given to derive filename.")
92+
93+
if part.destination.is_dir():
94+
destination = part.destination / part.source.name
95+
else:
96+
part.destination.parent.mkdir(parents=True, exist_ok=True)
97+
destination = part.destination
98+
99+
destination = part.destination
100+
if destination.is_dir():
101+
destination = destination / part.source.name
102+
103+
# If template=False and source is set, we just copy the file as is, without ever reading it
104+
if not part.template and part.source:
105+
shutil.copy(part.source, destination)
106+
return
107+
108+
if part.source:
109+
with open(part.source) as source_stream:
110+
template = source_stream.read()
111+
else:
112+
template = part.contents
113+
114+
if part.template:
115+
contents = self.render(template)
116+
else:
117+
contents = template
118+
119+
with open(destination, "w") as destination_stream:
120+
destination_stream.write(contents)
121+
86122
def run_parts(self) -> None:
87123
for part in self.tutorial.parts:
88124
if isinstance(part, CommandsPartModel):
89-
self.run_command(part)
90-
# elif isinstance(part, FilePartModel): # pragma: no cover
91-
# source = self.tutorial.path.parent / part.source
92-
# print(source)
125+
self.run_commands(part)
126+
elif isinstance(part, FilePartModel): # pragma: no cover
127+
self.write_file(part)
93128
else: # pragma: no cover
94129
raise RuntimeError(f"{part} is not a tutorial part")
95130

structured_tutorials/sphinx/utils.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from sphinx.config import Config
99
from sphinx.errors import ConfigError, ExtensionError
1010

11-
from structured_tutorials.models import CommandsPartModel, TutorialModel
11+
from structured_tutorials.models import CommandsPartModel, FilePartModel, TutorialModel
1212

1313

1414
def validate_configuration(app: Sphinx, config: Config) -> None:
@@ -50,6 +50,9 @@ def from_file(cls, path: Path) -> "TutorialWrapper":
5050
tutorial = TutorialModel.from_file(path)
5151
return cls(tutorial)
5252

53+
def render(self, template: str) -> str:
54+
return self.env.from_string(template).render(self.context)
55+
5356
def render_code_block(self, part: CommandsPartModel) -> str:
5457
"""Render a CommandsPartModel as a code-block."""
5558
commands = []
@@ -80,11 +83,43 @@ def render_code_block(self, part: CommandsPartModel) -> str:
8083
{% endfor %}"""
8184
return self.env.from_string(template).render({"commands": commands})
8285

86+
def render_file(self, part: FilePartModel) -> str:
87+
if part.template is False:
88+
return ""
89+
90+
template = part.contents
91+
if template is None:
92+
with open(part.path) as stream:
93+
template = stream.read()
94+
95+
content = self.render(template)
96+
97+
# get options
98+
if part.doc.caption:
99+
caption = self.render(part.doc.caption)
100+
elif part.doc.caption is not False:
101+
caption = self.render(str(part.destination))
102+
else:
103+
caption = ""
104+
105+
directive = """.. code-block:: {{ part.doc.language }}{% if caption %}
106+
:caption: {{ caption }}{% endif %}{% if part.doc.linenos or part.doc.lineno_start %}
107+
:linenos:{% endif %}{% if part.doc.lineno_start %}
108+
:lineno-start: {{ part.doc.lineno_start }}{% endif %}{% if part.doc.emphasize_lines %}
109+
:emphasize-lines: {{ part.doc.emphasize_lines }}{% endif %}{% if part.doc.name %}
110+
:name: {{ part.doc.name }}{% endif %}
111+
112+
{{ content }}
113+
"""
114+
return self.env.from_string(directive).render({"part": part, "content": content, "caption": caption})
115+
83116
def render_part(self) -> str:
84117
"""Render the given part of the tutorial."""
85118
part = self.tutorial.parts[self.next_part]
86119
if isinstance(part, CommandsPartModel):
87120
text = self.render_code_block(part)
121+
elif isinstance(part, FilePartModel):
122+
text = self.render_file(part)
88123
else: # pragma: no cover
89124
raise ValueError("unsupported part type.")
90125
self.next_part += 1

0 commit comments

Comments
 (0)