Skip to content

Commit 532c397

Browse files
committed
add option for skipping
1 parent f3696e4 commit 532c397

File tree

13 files changed

+394
-52
lines changed

13 files changed

+394
-52
lines changed

docs/reference.rst

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,32 @@ The second part will test if ``ncat`` is installed and call it after a three-sec
8787

8888
.. structured-tutorial-part::
8989

90+
Skip a part at runtime
91+
======================
92+
93+
To skip an entire part at runtime, but still show it in documentation, you can use the ``skip`` configuration:
94+
95+
.. literalinclude:: /tutorials/skip-part-run/tutorial.yaml
96+
:language: yaml
97+
98+
When running the tutorial, only the first part will run:
99+
100+
.. code-block:: console
101+
102+
user@host:~$ structured-tutorial docs/tutorials/skip-part-run/tutorial.yam
103+
+ ls /etc
104+
...
105+
106+
But when generating documentation, both parts will show, for example, this is part one:
107+
108+
.. structured-tutorial:: skip-part-run/tutorial.yaml
109+
110+
.. structured-tutorial-part::
111+
112+
... and this is part two:
113+
114+
.. structured-tutorial-part::
115+
90116
********************
91117
Documenting commands
92118
********************
@@ -155,3 +181,30 @@ This will render as:
155181
.. structured-tutorial:: prompt/tutorial.yaml
156182

157183
.. structured-tutorial-part::
184+
185+
Skip a part in documentation
186+
============================
187+
188+
To skip an entire part for documentation purposes, but still use it at runtime, you can use the ``skip``
189+
configuration:
190+
191+
.. literalinclude:: /tutorials/skip-part-doc/tutorial.yaml
192+
:language: yaml
193+
194+
When running the tutorial, only the first part will run:
195+
196+
.. code-block:: console
197+
198+
user@host:~$ structured-tutorial docs/tutorials/skip-part-run/tutorial.yaml
199+
+ ls /tmp
200+
...
201+
+ ls /etc
202+
...
203+
204+
But when generating documentation, only the first part can be used. Calling ``structured-tutorial-part`` a
205+
second time will lead to an error (as there are no parts left).
206+
207+
.. structured-tutorial:: skip-part-doc/tutorial.yaml
208+
209+
.. structured-tutorial-part::
210+
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
cwd: ../../
2+
configuration:
3+
doc:
4+
context:
5+
variable: at docs
6+
run:
7+
context:
8+
variable: at runtime
9+
parts:
10+
# Create a file with contents specified inline
11+
- contents: "inline contents: {{ variable }}"
12+
destination: build/inline_contents.txt
13+
# You can also disable rendering of contents as template
14+
- contents: "inline contents: {{ variable }}"
15+
destination: build/inline_contents.txt.template
16+
template: false
17+
# Create a file based on contents from another file.
18+
# NOTE: source path is relative to "this file" / "cwd from top-level configuration"
19+
- source: tests/data/tutorials/file_contents.txt
20+
destination: build/file_contents.txt
21+
# Create a file based on contents from another file, but do not render it
22+
# as a template (e.g. b/c you want to show the template, it's very large, a binary file, ...)
23+
- source: tests/data/tutorials/file_contents.txt
24+
destination: build/file_contents.txt.template
25+
template: false
26+
# You can also name a destination directory
27+
- source: tests/data/tutorials/file_contents.txt
28+
destination: build/subdir/
29+
template: false
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
parts:
2+
- commands:
3+
- command: ls /tmp
4+
doc:
5+
output: ...
6+
# We could also skip part at runtime instead:
7+
#run:
8+
# skip: true
9+
doc:
10+
skip: true
11+
- commands:
12+
- command: ls /etc
13+
doc:
14+
output: ...
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
parts:
2+
- commands:
3+
- command: ls /tmp
4+
doc:
5+
output: ...
6+
run:
7+
skip: true
8+
# We could also skip part in documentation instead:
9+
#doc:
10+
# skip: true
11+
- commands:
12+
- command: ls /etc
13+
doc:
14+
output: ...
15+
# Or we could skip just a command, not the entire part, at runtime:
16+
- command: ls /tmp
17+
run:
18+
skip: true
19+
doc:
20+
output: ...

docs/tutorials/test-command/tutorial.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ parts:
1010
- commands:
1111
# Verify that ncat is installed - apt install ncat
1212
- command: which ncat
13+
doc:
14+
output: /usr/bin/ncat
1315
# TODO: retrieve pid from stdout
1416
# Start an ncat echo server, but have a delay of three seconds
1517
- command: "sleep 3s && ncat -e /bin/cat -k -l 1234 &"

structured_tutorials/models.py

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Basic tutorial structure."""
22

3+
import os
34
from pathlib import Path
45
from typing import Annotated, Any, Literal, Self
56

@@ -34,6 +35,12 @@ class TestSpecificationMixin:
3435
backoff_factor: PositiveFloat = 0 # {backoff factor} * (2 ** ({number of previous retries}))
3536

3637

38+
class ConfigurationMixin:
39+
"""Mixin for configuration models."""
40+
41+
skip: bool = False
42+
43+
3744
class CleanupCommandModel(CommandBaseModel):
3845
"""Model for cleanup commands."""
3946

@@ -59,7 +66,7 @@ class TestPortModel(TestSpecificationMixin, BaseModel):
5966
port: Annotated[int, Field(ge=0, le=65535)]
6067

6168

62-
class CommandRuntimeConfigurationModel(CommandBaseModel):
69+
class CommandRuntimeConfigurationModel(ConfigurationMixin, CommandBaseModel):
6370
"""Model for runtime configuration when running a single command."""
6471

6572
model_config = ConfigDict(extra="forbid")
@@ -69,7 +76,7 @@ class CommandRuntimeConfigurationModel(CommandBaseModel):
6976
test: tuple[TestCommandModel | TestPortModel, ...] = tuple()
7077

7178

72-
class CommandDocumentationConfigurationModel(BaseModel):
79+
class CommandDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
7380
"""Model for documenting a single command."""
7481

7582
model_config = ConfigDict(extra="forbid")
@@ -88,6 +95,18 @@ class CommandModel(BaseModel):
8895
doc: CommandDocumentationConfigurationModel = CommandDocumentationConfigurationModel()
8996

9097

98+
class CommandsRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
99+
"""Runtime configuration for an entire commands part."""
100+
101+
model_config = ConfigDict(extra="forbid")
102+
103+
104+
class CommandsDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
105+
"""Documentation configuration for an entire commands part."""
106+
107+
model_config = ConfigDict(extra="forbid")
108+
109+
91110
class CommandsPartModel(BaseModel):
92111
"""Model for a set of commands."""
93112

@@ -96,11 +115,24 @@ class CommandsPartModel(BaseModel):
96115
type: Literal["commands"] = "commands"
97116
commands: tuple[CommandModel, ...]
98117

118+
run: CommandsRuntimeConfigurationModel = CommandsRuntimeConfigurationModel()
119+
doc: CommandsDocumentationConfigurationModel = CommandsDocumentationConfigurationModel()
120+
121+
122+
class FileRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
123+
"""Runtime configuration for a file part."""
124+
125+
model_config = ConfigDict(extra="forbid")
126+
127+
128+
class FileDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
129+
"""Documentation configuration for a file part."""
130+
131+
model_config = ConfigDict(extra="forbid")
99132

100-
class FileDocumentationConfigurationModel(BaseModel):
101133
# sphinx options:
102134
language: str = ""
103-
caption: str | Literal[False] = ""
135+
caption: str | Literal[False] = "" # template
104136
linenos: bool = False
105137
lineno_start: PositiveInt | Literal[False] = False
106138
emphasize_lines: str = ""
@@ -113,12 +145,13 @@ class FilePartModel(BaseModel):
113145
model_config = ConfigDict(extra="forbid")
114146

115147
type: Literal["file"] = "file"
116-
contents: str | None = None
117-
source: Path | None = None
118-
destination: Path
148+
contents: str | None = None # template if property is set
149+
source: Path | None = None # template if property is set
150+
destination: str # template
119151
template: bool = True
120152

121153
doc: FileDocumentationConfigurationModel = FileDocumentationConfigurationModel()
154+
run: FileRuntimeConfigurationModel = FileRuntimeConfigurationModel()
122155

123156
@field_validator("source", mode="after")
124157
@classmethod
@@ -135,6 +168,12 @@ def validate_contents_or_source(self) -> Self:
135168
raise ValueError("Only one of contents or source is allowed.")
136169
return self
137170

171+
@model_validator(mode="after")
172+
def validate_destination(self) -> Self:
173+
if not self.source and self.destination.endswith(os.sep):
174+
raise ValueError(f"{self.destination}: Destination must not be a directory if contents is given.")
175+
return self
176+
138177

139178
class RuntimeConfigurationModel(BaseModel):
140179
"""Model for configuration at runtime."""

structured_tutorials/runners/local.py

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

3+
import os
34
import shutil
45
import socket
56
import subprocess
67
import time
78
from copy import deepcopy
9+
from pathlib import Path
810

911
from jinja2 import Environment
1012

@@ -62,6 +64,9 @@ def run_test(self, test: TestCommandModel | TestPortModel) -> None:
6264

6365
def run_commands(self, part: CommandsPartModel) -> None:
6466
for command_config in part.commands:
67+
if command_config.run.skip:
68+
continue
69+
6570
# Render the command
6671
command = self.render(command_config.command)
6772

@@ -87,18 +92,20 @@ def run_commands(self, part: CommandsPartModel) -> None:
8792

8893
def write_file(self, part: FilePartModel) -> None:
8994
"""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.")
95+
destination = Path(part.destination)
9296

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
97+
if part.destination.endswith(os.path.sep):
98+
# TODO: could happen if destination is a template
99+
# if not part.source:
100+
# raise RuntimeError("Destination is directory, but no source given to derive filename.")
98101

99-
destination = part.destination
100-
if destination.is_dir():
102+
destination.mkdir(parents=True, exist_ok=True)
101103
destination = destination / part.source.name
104+
elif destination.exists():
105+
raise RuntimeError(f"{destination}: Destination already exists.")
106+
107+
# Create any parent directories
108+
destination.parent.mkdir(parents=True, exist_ok=True)
102109

103110
# If template=False and source is set, we just copy the file as is, without ever reading it
104111
if not part.template and part.source:
@@ -121,9 +128,12 @@ def write_file(self, part: FilePartModel) -> None:
121128

122129
def run_parts(self) -> None:
123130
for part in self.tutorial.parts:
131+
if part.run.skip:
132+
continue
133+
124134
if isinstance(part, CommandsPartModel):
125135
self.run_commands(part)
126-
elif isinstance(part, FilePartModel): # pragma: no cover
136+
elif isinstance(part, FilePartModel):
127137
self.write_file(part)
128138
else: # pragma: no cover
129139
raise RuntimeError(f"{part} is not a tutorial part")

0 commit comments

Comments
 (0)