docsmoke 1.0.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.
NOTICE ADDED
@@ -0,0 +1,10 @@
1
+ docsmoke
2
+ Copyright 2026 Uğur Kontel
3
+
4
+ This product includes software developed by Uğur Kontel.
5
+
6
+ Licensed under the Apache License, Version 2.0 (the "License");
7
+ you may not use this file except in compliance with the License.
8
+ You may obtain a copy of the License at
9
+
10
+ http://www.apache.org/licenses/LICENSE-2.0
docsmoke/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """docsmoke public package metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ __all__ = ["__version__"]
8
+
9
+ try:
10
+ __version__ = version("docsmoke")
11
+ except PackageNotFoundError: # pragma: no cover
12
+ __version__ = "0.0.0+unknown"
docsmoke/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from docsmoke.cli import app
2
+
3
+ app()
docsmoke/cli.py ADDED
@@ -0,0 +1,184 @@
1
+ """Command-line interface for docsmoke."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path # noqa: TC003 - Typer resolves path annotations at runtime
7
+ from typing import Annotated
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from docsmoke import __version__
14
+ from docsmoke.config import Config
15
+ from docsmoke.config import load as load_config
16
+ from docsmoke.exceptions import ConfigError, DirectiveError
17
+ from docsmoke.reporting import render, write
18
+ from docsmoke.runner import collect_snippets
19
+ from docsmoke.runner import scan as run_scan
20
+
21
+ app = typer.Typer(
22
+ name="docsmoke",
23
+ help="Validate executable Markdown documentation snippets.",
24
+ add_completion=False,
25
+ no_args_is_help=True,
26
+ )
27
+
28
+
29
+ def _version_callback(value: bool) -> None:
30
+ if value:
31
+ typer.echo(f"docsmoke {__version__}")
32
+ raise typer.Exit()
33
+
34
+
35
+ def _resolve_config(
36
+ config_path: Path | None,
37
+ *,
38
+ all_supported: bool,
39
+ fail_fast: bool | None,
40
+ ) -> Config:
41
+ config = load_config(config_path)
42
+ if all_supported:
43
+ config.require_directive = False
44
+ if fail_fast is not None:
45
+ config.fail_fast = fail_fast
46
+ return config
47
+
48
+
49
+ @app.callback()
50
+ def _root(
51
+ _version: Annotated[
52
+ bool | None,
53
+ typer.Option(
54
+ "--version",
55
+ callback=_version_callback,
56
+ is_eager=True,
57
+ help="Show version and exit.",
58
+ ),
59
+ ] = None,
60
+ ) -> None:
61
+ """Executable documentation smoke tests for Markdown."""
62
+
63
+
64
+ @app.command()
65
+ def scan(
66
+ paths: Annotated[
67
+ list[Path] | None,
68
+ typer.Argument(
69
+ help="Markdown files or directories to scan. Defaults to configured include globs."
70
+ ),
71
+ ] = None,
72
+ config_path: Annotated[
73
+ Path | None,
74
+ typer.Option("--config", help="Path to docsmoke.toml or pyproject.toml."),
75
+ ] = None,
76
+ output: Annotated[
77
+ Path | None,
78
+ typer.Option("--output", "-o", help="Write a machine-readable report."),
79
+ ] = None,
80
+ fmt: Annotated[
81
+ str,
82
+ typer.Option("--format", "-f", help="Report format for --output: json or markdown."),
83
+ ] = "json",
84
+ quiet: Annotated[
85
+ bool,
86
+ typer.Option("--quiet", "-q", help="Suppress console report output."),
87
+ ] = False,
88
+ all_supported: Annotated[
89
+ bool,
90
+ typer.Option(
91
+ "--all-supported",
92
+ help="Execute every supported fenced block, even when not explicitly marked.",
93
+ ),
94
+ ] = False,
95
+ fail_fast: Annotated[
96
+ bool | None,
97
+ typer.Option("--fail-fast/--no-fail-fast", help="Stop after the first failing snippet."),
98
+ ] = None,
99
+ ) -> None:
100
+ """Scan Markdown files, execute snippets, and report the results."""
101
+ if output is not None and fmt not in {"json", "markdown"}:
102
+ typer.secho("--format must be 'json' or 'markdown'", fg=typer.colors.RED, err=True)
103
+ raise typer.Exit(code=2)
104
+
105
+ try:
106
+ paths = paths or []
107
+ config = _resolve_config(config_path, all_supported=all_supported, fail_fast=fail_fast)
108
+ report = run_scan(paths, config=config)
109
+ except (ConfigError, DirectiveError) as exc:
110
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
111
+ raise typer.Exit(code=2) from None
112
+
113
+ if not quiet:
114
+ typer.echo(render(report, "console"))
115
+
116
+ if output is not None:
117
+ write(report, output, fmt)
118
+
119
+ if report.has_failures:
120
+ raise typer.Exit(code=2)
121
+
122
+
123
+ @app.command("list-snippets")
124
+ def list_snippets(
125
+ paths: Annotated[
126
+ list[Path] | None,
127
+ typer.Argument(
128
+ help="Markdown files or directories to inspect. Defaults to configured include globs."
129
+ ),
130
+ ] = None,
131
+ config_path: Annotated[
132
+ Path | None,
133
+ typer.Option("--config", help="Path to docsmoke.toml or pyproject.toml."),
134
+ ] = None,
135
+ as_json: Annotated[
136
+ bool,
137
+ typer.Option("--json", help="Emit JSON instead of a table."),
138
+ ] = False,
139
+ all_supported: Annotated[
140
+ bool,
141
+ typer.Option(
142
+ "--all-supported",
143
+ help="List every supported fenced block, even when not explicitly marked.",
144
+ ),
145
+ ] = False,
146
+ ) -> None:
147
+ """List snippets that docsmoke would execute."""
148
+ try:
149
+ paths = paths or []
150
+ config = _resolve_config(config_path, all_supported=all_supported, fail_fast=None)
151
+ snippets = collect_snippets(paths, config=config)
152
+ except (ConfigError, DirectiveError) as exc:
153
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
154
+ raise typer.Exit(code=2) from None
155
+
156
+ if as_json:
157
+ payload = [
158
+ {
159
+ "id": snippet.identifier,
160
+ "path": str(snippet.path),
161
+ "language": snippet.language,
162
+ "executor": snippet.executor,
163
+ "name": snippet.display_name,
164
+ "line": snippet.start_line,
165
+ }
166
+ for snippet in snippets
167
+ ]
168
+ typer.echo(json.dumps(payload, indent=2, ensure_ascii=False))
169
+ return
170
+
171
+ table = Table(title="docsmoke snippets")
172
+ table.add_column("Snippet")
173
+ table.add_column("Language")
174
+ table.add_column("Executor")
175
+ table.add_column("Location")
176
+ for snippet in snippets:
177
+ table.add_row(
178
+ snippet.display_name,
179
+ snippet.language,
180
+ snippet.executor,
181
+ f"{snippet.path}:{snippet.start_line}",
182
+ )
183
+ console = Console()
184
+ console.print(table)
docsmoke/config.py ADDED
@@ -0,0 +1,98 @@
1
+ """Configuration loading and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from docsmoke.exceptions import ConfigError
10
+
11
+ try:
12
+ import tomllib
13
+ except ModuleNotFoundError: # pragma: no cover
14
+ import tomli as tomllib
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class Config:
19
+ """User-configurable scan defaults."""
20
+
21
+ project_root: Path = field(default_factory=Path.cwd)
22
+ include: tuple[str, ...] = ("README.md", "docs/**/*.md")
23
+ exclude: tuple[str, ...] = (".venv/**", "build/**", "dist/**")
24
+ default_timeout: float = 10.0
25
+ fail_fast: bool = False
26
+ require_directive: bool = True
27
+
28
+ def __post_init__(self) -> None:
29
+ if self.default_timeout <= 0:
30
+ raise ConfigError("default_timeout must be greater than 0")
31
+ if not self.include:
32
+ raise ConfigError("include must contain at least one glob")
33
+ if not all(isinstance(item, str) and item for item in self.include):
34
+ raise ConfigError("include must contain non-empty strings")
35
+ if not all(isinstance(item, str) and item for item in self.exclude):
36
+ raise ConfigError("exclude must contain non-empty strings")
37
+
38
+
39
+ def load(config_path: Path | None = None) -> Config:
40
+ """Load configuration from docsmoke.toml or pyproject.toml when present."""
41
+ candidate = config_path
42
+ if candidate is None:
43
+ docsmoke_file = Path.cwd() / "docsmoke.toml"
44
+ pyproject_file = Path.cwd() / "pyproject.toml"
45
+ if docsmoke_file.exists():
46
+ candidate = docsmoke_file
47
+ elif pyproject_file.exists():
48
+ candidate = pyproject_file
49
+
50
+ if candidate is None:
51
+ return Config(project_root=Path.cwd())
52
+
53
+ if not candidate.exists():
54
+ raise ConfigError(f"config file does not exist: {candidate}")
55
+
56
+ data = tomllib.loads(candidate.read_text(encoding="utf-8"))
57
+ source = data if candidate.name == "docsmoke.toml" else data.get("tool", {}).get("docsmoke", {})
58
+ if not isinstance(source, dict):
59
+ raise ConfigError("docsmoke config section must be a TOML table")
60
+
61
+ return _from_mapping(source, project_root=candidate.parent)
62
+
63
+
64
+ def _from_mapping(mapping: dict[str, Any], *, project_root: Path) -> Config:
65
+ include = _tuple_of_strings(mapping.get("include", ("README.md", "docs/**/*.md")), "include")
66
+ exclude = _tuple_of_strings(
67
+ mapping.get("exclude", (".venv/**", "build/**", "dist/**")), "exclude"
68
+ )
69
+ default_timeout = mapping.get("default_timeout", 10.0)
70
+ fail_fast = mapping.get("fail_fast", False)
71
+ require_directive = mapping.get("require_directive", True)
72
+
73
+ if not isinstance(default_timeout, (int, float)) or isinstance(default_timeout, bool):
74
+ raise ConfigError("default_timeout must be a positive number")
75
+ if not isinstance(fail_fast, bool):
76
+ raise ConfigError("fail_fast must be a boolean")
77
+ if not isinstance(require_directive, bool):
78
+ raise ConfigError("require_directive must be a boolean")
79
+
80
+ return Config(
81
+ project_root=project_root.resolve(),
82
+ include=include,
83
+ exclude=exclude,
84
+ default_timeout=float(default_timeout),
85
+ fail_fast=fail_fast,
86
+ require_directive=require_directive,
87
+ )
88
+
89
+
90
+ def _tuple_of_strings(value: Any, field_name: str) -> tuple[str, ...]:
91
+ if isinstance(value, str):
92
+ return (value,)
93
+ if not isinstance(value, (list, tuple)):
94
+ raise ConfigError(f"{field_name} must be a string or list of strings")
95
+ items = tuple(value)
96
+ if not all(isinstance(item, str) and item for item in items):
97
+ raise ConfigError(f"{field_name} must contain non-empty strings")
98
+ return items
docsmoke/exceptions.py ADDED
@@ -0,0 +1,13 @@
1
+ """docsmoke exception hierarchy."""
2
+
3
+
4
+ class DocsmokeError(Exception):
5
+ """Base error for user-facing docsmoke failures."""
6
+
7
+
8
+ class ConfigError(DocsmokeError):
9
+ """Invalid configuration or user input."""
10
+
11
+
12
+ class DirectiveError(DocsmokeError):
13
+ """Invalid docsmoke directive embedded in a snippet."""
docsmoke/executor.py ADDED
@@ -0,0 +1,162 @@
1
+ """Snippet execution primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from docsmoke.models import Snippet, SnippetResult, SnippetStatus
13
+
14
+
15
+ def run_snippet(snippet: Snippet, *, project_root: Path, default_timeout: float) -> SnippetResult:
16
+ """Execute a single snippet and evaluate its expectations."""
17
+ if snippet.directives.skip:
18
+ return SnippetResult(
19
+ snippet=snippet,
20
+ status=SnippetStatus.skipped,
21
+ duration_seconds=0.0,
22
+ exit_code=None,
23
+ stdout="",
24
+ stderr="",
25
+ message="snippet marked as skipped",
26
+ )
27
+
28
+ if not snippet.code.strip():
29
+ return SnippetResult(
30
+ snippet=snippet,
31
+ status=SnippetStatus.error,
32
+ duration_seconds=0.0,
33
+ exit_code=None,
34
+ stdout="",
35
+ stderr="",
36
+ message="snippet body is empty",
37
+ )
38
+
39
+ timeout = snippet.directives.timeout or default_timeout
40
+ cwd = _resolve_cwd(project_root, snippet)
41
+ if not cwd.exists():
42
+ return SnippetResult(
43
+ snippet=snippet,
44
+ status=SnippetStatus.error,
45
+ duration_seconds=0.0,
46
+ exit_code=None,
47
+ stdout="",
48
+ stderr="",
49
+ message=f"working directory does not exist: {cwd}",
50
+ )
51
+ if not cwd.is_dir():
52
+ return SnippetResult(
53
+ snippet=snippet,
54
+ status=SnippetStatus.error,
55
+ duration_seconds=0.0,
56
+ exit_code=None,
57
+ stdout="",
58
+ stderr="",
59
+ message=f"working directory is not a directory: {cwd}",
60
+ )
61
+ env = os.environ.copy()
62
+ env["PATH"] = _prepend_executable_dir(env.get("PATH", ""))
63
+ env.update(snippet.directives.env)
64
+ command = _build_command(snippet)
65
+
66
+ started = time.perf_counter()
67
+ try:
68
+ completed = subprocess.run(
69
+ command,
70
+ cwd=cwd,
71
+ env=env,
72
+ capture_output=True,
73
+ text=True,
74
+ timeout=timeout,
75
+ check=False,
76
+ )
77
+ except FileNotFoundError as exc:
78
+ duration = time.perf_counter() - started
79
+ return SnippetResult(
80
+ snippet=snippet,
81
+ status=SnippetStatus.error,
82
+ duration_seconds=duration,
83
+ exit_code=None,
84
+ stdout="",
85
+ stderr="",
86
+ message=f"executor not available: {exc.filename}",
87
+ )
88
+ except subprocess.TimeoutExpired as exc:
89
+ duration = time.perf_counter() - started
90
+ return SnippetResult(
91
+ snippet=snippet,
92
+ status=SnippetStatus.error,
93
+ duration_seconds=duration,
94
+ exit_code=None,
95
+ stdout=_coerce_output(exc.stdout),
96
+ stderr=_coerce_output(exc.stderr),
97
+ message=f"timed out after {timeout:.1f}s",
98
+ )
99
+
100
+ duration = time.perf_counter() - started
101
+ status, message = _evaluate(snippet, completed.returncode, completed.stdout, completed.stderr)
102
+ return SnippetResult(
103
+ snippet=snippet,
104
+ status=status,
105
+ duration_seconds=duration,
106
+ exit_code=completed.returncode,
107
+ stdout=completed.stdout,
108
+ stderr=completed.stderr,
109
+ message=message,
110
+ )
111
+
112
+
113
+ def _build_command(snippet: Snippet) -> list[str]:
114
+ if snippet.executor == "python":
115
+ return [sys.executable, "-c", snippet.code]
116
+ return [snippet.directives.shell or snippet.executor, "-eu", "-c", snippet.code]
117
+
118
+
119
+ def _resolve_cwd(project_root: Path, snippet: Snippet) -> Path:
120
+ if snippet.directives.cwd is None:
121
+ return project_root
122
+ return (project_root / snippet.directives.cwd).resolve()
123
+
124
+
125
+ def _evaluate(
126
+ snippet: Snippet,
127
+ exit_code: int,
128
+ stdout: str,
129
+ stderr: str,
130
+ ) -> tuple[SnippetStatus, str]:
131
+ if exit_code != 0:
132
+ return SnippetStatus.failed, f"snippet exited with code {exit_code}"
133
+
134
+ combined = stdout + stderr
135
+ for expected in snippet.directives.expect_contains:
136
+ if expected not in combined:
137
+ return SnippetStatus.failed, f"missing expected text: {expected!r}"
138
+
139
+ for pattern in snippet.directives.expect_regex:
140
+ try:
141
+ matched = re.search(pattern, combined, flags=re.MULTILINE)
142
+ except re.error as exc:
143
+ return SnippetStatus.error, f"invalid regex {pattern!r}: {exc}"
144
+ if matched is None:
145
+ return SnippetStatus.failed, f"missing regex match: {pattern!r}"
146
+
147
+ return SnippetStatus.passed, "ok"
148
+
149
+
150
+ def _coerce_output(value: bytes | str | None) -> str:
151
+ if value is None:
152
+ return ""
153
+ if isinstance(value, bytes):
154
+ return value.decode(errors="replace")
155
+ return value
156
+
157
+
158
+ def _prepend_executable_dir(path_value: str) -> str:
159
+ executable_dir = Path(sys.executable).parent
160
+ if path_value:
161
+ return f"{executable_dir}{os.pathsep}{path_value}"
162
+ return str(executable_dir)
docsmoke/markdown.py ADDED
@@ -0,0 +1,211 @@
1
+ """Markdown fenced-block discovery and docsmoke directive parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING
7
+
8
+ from docsmoke.exceptions import DirectiveError
9
+ from docsmoke.models import Snippet, SnippetDirectives
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ DIRECTIVE_PATTERNS = (
15
+ re.compile(r"^\s*#\s*docsmoke:\s*(?P<body>.+?)\s*$"),
16
+ re.compile(r"^\s*//\s*docsmoke:\s*(?P<body>.+?)\s*$"),
17
+ re.compile(r"^\s*<!--\s*docsmoke:\s*(?P<body>.+?)\s*-->\s*$"),
18
+ )
19
+ OPENING_FENCE_PATTERN = re.compile(r"^(?P<indent> {0,3})(?P<fence>`{3,}|~{3,})(?P<info>.*)$")
20
+
21
+ SUPPORTED_EXECUTORS = {
22
+ "bash": "bash",
23
+ "sh": "sh",
24
+ "shell": "sh",
25
+ "zsh": "zsh",
26
+ "python": "python",
27
+ "py": "python",
28
+ }
29
+
30
+
31
+ def discover_snippets(path: Path, *, require_directive: bool = True) -> list[Snippet]:
32
+ """Parse a Markdown file and return runnable docsmoke snippets."""
33
+ lines = path.read_text(encoding="utf-8").splitlines()
34
+ snippets: list[Snippet] = []
35
+ index = 0
36
+
37
+ while index < len(lines):
38
+ line = lines[index]
39
+ opening_fence = _parse_opening_fence(line)
40
+ if opening_fence is None:
41
+ index += 1
42
+ continue
43
+
44
+ fence_char, fence_length, info = opening_fence
45
+ opening_line = index + 1
46
+ index += 1
47
+ body: list[str] = []
48
+ while index < len(lines) and not _is_closing_fence(
49
+ lines[index], fence_char=fence_char, fence_length=fence_length
50
+ ):
51
+ body.append(lines[index])
52
+ index += 1
53
+
54
+ closing_line = index + 1 if index < len(lines) else len(lines)
55
+ language, opted_in = _parse_info_string(info)
56
+ executor = SUPPORTED_EXECUTORS.get(language)
57
+
58
+ if executor is None:
59
+ index += 1
60
+ continue
61
+
62
+ directives, code, has_directives = _parse_directives(body, path=path, line=opening_line)
63
+ if require_directive and not opted_in and not has_directives:
64
+ index += 1
65
+ continue
66
+
67
+ snippets.append(
68
+ Snippet(
69
+ path=path,
70
+ language=language,
71
+ executor=executor,
72
+ code="\n".join(code).rstrip(),
73
+ start_line=opening_line,
74
+ end_line=closing_line,
75
+ directives=directives,
76
+ )
77
+ )
78
+ index += 1
79
+
80
+ return snippets
81
+
82
+
83
+ def _parse_opening_fence(line: str) -> tuple[str, int, str] | None:
84
+ match = OPENING_FENCE_PATTERN.match(line)
85
+ if match is None:
86
+ return None
87
+ fence = match.group("fence")
88
+ return fence[0], len(fence), match.group("info").strip()
89
+
90
+
91
+ def _is_closing_fence(line: str, *, fence_char: str, fence_length: int) -> bool:
92
+ candidate = line.lstrip(" ")
93
+ if len(line) - len(candidate) > 3:
94
+ return False
95
+
96
+ marker_count = 0
97
+ for character in candidate:
98
+ if character != fence_char:
99
+ break
100
+ marker_count += 1
101
+
102
+ if marker_count < fence_length:
103
+ return False
104
+ return candidate[marker_count:].strip() == ""
105
+
106
+
107
+ def _parse_info_string(info: str) -> tuple[str, bool]:
108
+ tokens = info.split()
109
+ if not tokens:
110
+ return "", False
111
+ language = tokens[0].lower()
112
+ opted_in = any(token.lower() == "docsmoke" for token in tokens[1:])
113
+ return language, opted_in
114
+
115
+
116
+ def _parse_directives(
117
+ body: list[str],
118
+ *,
119
+ path: Path,
120
+ line: int,
121
+ ) -> tuple[SnippetDirectives, list[str], bool]:
122
+ directives = SnippetDirectives()
123
+ directive_count = 0
124
+ code = list(body)
125
+
126
+ while code:
127
+ payload = _directive_payload(code[0])
128
+ if payload is None:
129
+ break
130
+ directive_count += 1
131
+ _apply_directive_payload(directives, payload, path=path, line=line + directive_count)
132
+ code.pop(0)
133
+
134
+ return directives, code, directive_count > 0
135
+
136
+
137
+ def _directive_payload(line: str) -> str | None:
138
+ for pattern in DIRECTIVE_PATTERNS:
139
+ match = pattern.match(line)
140
+ if match:
141
+ return match.group("body")
142
+ return None
143
+
144
+
145
+ def _apply_directive_payload(
146
+ directives: SnippetDirectives,
147
+ payload: str,
148
+ *,
149
+ path: Path,
150
+ line: int,
151
+ ) -> None:
152
+ for part in payload.split(";"):
153
+ item = part.strip()
154
+ if not item:
155
+ continue
156
+ key, has_sep, value = item.partition("=")
157
+ raw_key = key.strip()
158
+ normalized_key = raw_key.lower()
159
+ normalized_value = value.strip()
160
+
161
+ if normalized_key == "name":
162
+ _require_value(has_sep, normalized_value, key=normalized_key, path=path, line=line)
163
+ directives.name = normalized_value
164
+ elif normalized_key == "cwd":
165
+ _require_value(has_sep, normalized_value, key=normalized_key, path=path, line=line)
166
+ directives.cwd = normalized_value
167
+ elif normalized_key == "timeout":
168
+ _require_value(has_sep, normalized_value, key=normalized_key, path=path, line=line)
169
+ try:
170
+ directives.timeout = float(normalized_value)
171
+ except ValueError as exc:
172
+ raise DirectiveError(
173
+ f"{path}:{line}: timeout must be numeric, got {normalized_value!r}"
174
+ ) from exc
175
+ if directives.timeout <= 0:
176
+ raise DirectiveError(f"{path}:{line}: timeout must be greater than 0")
177
+ elif normalized_key == "expect-contains":
178
+ _require_value(has_sep, normalized_value, key=normalized_key, path=path, line=line)
179
+ directives.expect_contains += (normalized_value,)
180
+ elif normalized_key == "expect-regex":
181
+ _require_value(has_sep, normalized_value, key=normalized_key, path=path, line=line)
182
+ directives.expect_regex += (normalized_value,)
183
+ elif normalized_key == "shell":
184
+ _require_value(has_sep, normalized_value, key=normalized_key, path=path, line=line)
185
+ directives.shell = normalized_value
186
+ elif normalized_key == "skip":
187
+ directives.skip = _parse_bool(
188
+ normalized_value if has_sep else "true", path=path, line=line
189
+ )
190
+ elif normalized_key.startswith("env."):
191
+ _require_value(has_sep, normalized_value, key=normalized_key, path=path, line=line)
192
+ env_name = raw_key[4:]
193
+ if not env_name:
194
+ raise DirectiveError(f"{path}:{line}: env directives require a variable name")
195
+ directives.env[env_name] = normalized_value
196
+ else:
197
+ raise DirectiveError(f"{path}:{line}: unknown docsmoke directive {normalized_key!r}")
198
+
199
+
200
+ def _require_value(has_sep: str, value: str, *, key: str, path: Path, line: int) -> None:
201
+ if has_sep != "=" or value == "":
202
+ raise DirectiveError(f"{path}:{line}: directive {key!r} requires a value")
203
+
204
+
205
+ def _parse_bool(value: str, *, path: Path, line: int) -> bool:
206
+ normalized = value.lower()
207
+ if normalized in {"1", "true", "yes", "on"}:
208
+ return True
209
+ if normalized in {"0", "false", "no", "off"}:
210
+ return False
211
+ raise DirectiveError(f"{path}:{line}: invalid boolean value {value!r}")