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 +10 -0
- docsmoke/__init__.py +12 -0
- docsmoke/__main__.py +3 -0
- docsmoke/cli.py +184 -0
- docsmoke/config.py +98 -0
- docsmoke/exceptions.py +13 -0
- docsmoke/executor.py +162 -0
- docsmoke/markdown.py +211 -0
- docsmoke/models.py +133 -0
- docsmoke/py.typed +0 -0
- docsmoke/reporting/__init__.py +32 -0
- docsmoke/reporting/console.py +42 -0
- docsmoke/reporting/json.py +13 -0
- docsmoke/reporting/markdown.py +43 -0
- docsmoke/runner.py +78 -0
- docsmoke-1.0.0.dist-info/METADATA +359 -0
- docsmoke-1.0.0.dist-info/RECORD +21 -0
- docsmoke-1.0.0.dist-info/WHEEL +4 -0
- docsmoke-1.0.0.dist-info/entry_points.txt +2 -0
- docsmoke-1.0.0.dist-info/licenses/LICENSE +201 -0
- docsmoke-1.0.0.dist-info/licenses/NOTICE +10 -0
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
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}")
|