Skip to content

Commit 5aad28d

Browse files
committed
Add the first implementation of line filtering
This is a custom superfence for pymdown-extensions that can filter lines by specifying a set of line ranges to show. The ranges are specified in the `show_lines` option, which is a comma-separated list of ranges, where each range is in the format `start:end` or `line`, where `start`, `end` and `line` are positive integers. If `start` is empty, it is assumed to be 1. If `end` is empty, it is assumed to be the end of the file. If only `line` is used, it is assumed to be both `start` and `end` (so allowing just one line). Lines are 1-indexed. Warnings are emitted if the `show_lines` option is invalid. There is a hacky way to determine if we are running inside MkDocs or not, and if we are then we use the MkDocs logger, so warnings are shown in the MkDocs output with special formatting and also are detected as such when running in *strict* mode. If we are not running inside MkDocs, then we use the logger for this module. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 96a52df commit 5aad28d

File tree

2 files changed

+275
-2
lines changed

2 files changed

+275
-2
lines changed

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ classifiers = [
3939
"Typing :: Typed",
4040
]
4141
requires-python = ">= 3.11, < 4"
42+
dependencies = ["Markdown >= 3.5, < 4", "pymdown-extensions >= 10, < 11"]
4243
dynamic = ["version"]
4344

4445
[[project.authors]]
@@ -56,7 +57,7 @@ dev-flake8 = [
5657
dev-formatting = ["black == 23.9.1", "isort == 5.12.0"]
5758
dev-mkdocs = [
5859
"black == 23.9.1",
59-
"Markdown==3.4.4",
60+
"Markdown==3.5",
6061
"mike == 2.0.0",
6162
"mkdocs-gen-files == 0.5.0",
6263
"mkdocs-literate-nav == 0.6.1",
@@ -161,7 +162,7 @@ packages = ["frequenz.pymdownx.superfences.filter_lines"]
161162
strict = true
162163

163164
[[tool.mypy.overrides]]
164-
module = ["mkdocs_macros.*", "sybil", "sybil.*"]
165+
module = ["mkdocs_macros.*", "pymdownx.*", "sybil", "sybil.*"]
165166
ignore_missing_imports = true
166167

167168
[tool.setuptools_scm]
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
# License: MIT
2+
# Copyright © 2023 Frequenz Energy-as-a-Service GmbH
3+
4+
"""A custom superfence for pymdown-extensions that can filters lines and plays nice with MkDocs."""
5+
6+
import dataclasses
7+
import inspect
8+
import logging
9+
from typing import Any, NotRequired, Self, Set, TypedDict, cast
10+
11+
import markdown
12+
from pymdownx.superfences import highlight_validator
13+
14+
15+
@dataclasses.dataclass(frozen=True, kw_only=True)
16+
class LinesRange:
17+
"""A range of lines.
18+
19+
`start` and `end` are inclusive and the indices start from 1.
20+
"""
21+
22+
start: int | None = None
23+
"""Start line.
24+
25+
`None` means the beginning of the file.
26+
"""
27+
28+
end: int | None = None
29+
"""End line.
30+
31+
`None` means the end of the file.
32+
"""
33+
34+
def __post_init__(self) -> None:
35+
"""Validate inputs upon creation."""
36+
if self.start is None and self.end is None:
37+
raise ValueError("Cannot have both start and end as `None`")
38+
if self.start is not None and self.start < 1:
39+
raise ValueError("Start must be at least 1")
40+
if self.end is not None and self.end < 1:
41+
raise ValueError("End must be at least 1")
42+
if self.start is not None and self.end is not None and self.start > self.end:
43+
raise ValueError("Start must be less than or equal to end")
44+
45+
def __contains__(self, item: int) -> bool:
46+
"""Whether the item is inside this range."""
47+
if self.start is None:
48+
assert self.end is not None
49+
return item <= self.end
50+
51+
if self.end is None:
52+
return self.start <= item
53+
54+
return self.start <= item <= self.end
55+
56+
@classmethod
57+
def parse(cls, text: str) -> Self:
58+
"""Create from a string.
59+
60+
The string can be in the following formats:
61+
62+
- `start`
63+
- `start:end`
64+
- `:end`
65+
- `start:`
66+
67+
where `start` and `end` are integers.
68+
69+
If `start` is empty, it is assumed to be 1.
70+
If `end` is empty, it is assumed to be the end of the file.
71+
If `start` appears without `:`, it is assumed to be both `start` and `end`.
72+
73+
Lines are 1-indexed.
74+
75+
Args:
76+
text: String to parse.
77+
78+
Returns:
79+
The created range.
80+
81+
Raises:
82+
ValueError: If the string is invalid.
83+
"""
84+
splitted = text.split(":", 1)
85+
stripped = map(lambda s: s.strip(), splitted)
86+
match tuple(map(lambda s: s if s else None, stripped)):
87+
case (None,):
88+
raise ValueError("Empty start")
89+
case () | (None, None):
90+
raise ValueError("Both start and end are empty")
91+
case (str() as start,):
92+
return cls(start=int(start), end=int(start))
93+
case (str() as start, None):
94+
return cls(start=int(start))
95+
case (None, str() as end):
96+
return cls(end=int(end))
97+
case (str() as start, str() as end):
98+
return cls(start=int(start), end=int(end))
99+
case _ as invalid:
100+
raise ValueError(f"Invalid: {invalid!r}")
101+
102+
def __str__(self) -> str:
103+
"""Get the string representation."""
104+
if self.start is None:
105+
assert self.end is not None
106+
return f":{self.end}"
107+
if self.end is None:
108+
return f"{self.start}:"
109+
return f"{self.start}:{self.end}"
110+
111+
112+
@dataclasses.dataclass(frozen=True)
113+
class LinesRanges:
114+
"""A set of line ranges."""
115+
116+
ranges: Set[LinesRange]
117+
"""The lines ranges."""
118+
119+
def __post_init__(self) -> None:
120+
"""Validate."""
121+
if not self.ranges:
122+
raise ValueError("Cannot have empty ranges")
123+
124+
def __contains__(self, item: int) -> bool:
125+
"""Whether the item is inside any of the ranges."""
126+
return any(item in r for r in self.ranges)
127+
128+
@classmethod
129+
def parse(cls, text: str) -> tuple[Self | None, list[ValueError]]:
130+
"""Create from a string.
131+
132+
The string must be a comma-separated list of ranges, where each range is in the
133+
format described in
134+
[`LinesRange.parse`][frequenz.pymdownx.superfences.filter_lines.LinesRange.parse].
135+
136+
If no ranges are given, `None` is returned. If any ranges are invalid, they are
137+
ignored and a list of errors is returned.
138+
139+
Args:
140+
text: String to parse.
141+
142+
Returns:
143+
The created ranges, or `None` if no ranges are given, plus a list of errors,
144+
if any.
145+
"""
146+
ranges: set[LinesRange] = set()
147+
errors: list[ValueError] = []
148+
for n, range_str in enumerate(text.split(","), start=1):
149+
try:
150+
lines_range = LinesRange.parse(range_str.strip())
151+
except ValueError as exc:
152+
error = ValueError(f"Range {n} ({range_str!r}) is invalid: {exc}")
153+
error.__cause__ = exc
154+
errors.append(error)
155+
continue
156+
ranges.add(lines_range)
157+
return cls(ranges) if ranges else None, errors
158+
159+
def __str__(self) -> str:
160+
"""Get the string representation."""
161+
return ",".join(map(str, self.ranges))
162+
163+
164+
class Inputs(TypedDict):
165+
"""Raw input options before they are validated."""
166+
167+
show_lines: NotRequired[str]
168+
"""Lines to show option."""
169+
170+
171+
class Options(TypedDict):
172+
"""Raw options before they are validated."""
173+
174+
show_lines: NotRequired[LinesRanges]
175+
"""Lines to show."""
176+
177+
178+
def do_validate(
179+
language: str,
180+
inputs: Inputs,
181+
options: Options,
182+
attrs: dict[str, Any],
183+
md: markdown.Markdown,
184+
) -> bool:
185+
"""Validate the inputs."""
186+
# Parse `show_lines` option
187+
if show_lines_option := inputs.get("show_lines"):
188+
lines_ranges, errors = LinesRanges.parse(show_lines_option)
189+
190+
for error in errors:
191+
_warn(
192+
"Invalid `show_lines` option in %r, some lines will not be filtered: %s",
193+
show_lines_option,
194+
error,
195+
)
196+
197+
if lines_ranges:
198+
options["show_lines"] = lines_ranges
199+
200+
# Remove handled option from inputs
201+
del inputs["show_lines"]
202+
203+
# Run default validator
204+
return cast(bool, highlight_validator(language, inputs, options, attrs, md))
205+
206+
207+
def do_format(
208+
src: str,
209+
language: str,
210+
class_name: str,
211+
options: Options,
212+
md: markdown.Markdown,
213+
**kwargs: Any,
214+
) -> Any:
215+
"""Filter the lines and run the default highlighter."""
216+
# Filter the lines to show
217+
if show_lines := options.get("show_lines"):
218+
lines = []
219+
for n, line in enumerate(src.splitlines(keepends=True), 1):
220+
if n in show_lines:
221+
lines.append(line)
222+
src = "".join(lines)
223+
224+
# Run through default highlighter
225+
return md.preprocessors["fenced_code_block"].highlight(
226+
src=src,
227+
class_name=class_name,
228+
language=language,
229+
md=md,
230+
options=options,
231+
**kwargs,
232+
)
233+
234+
235+
def _warn(msg: str, /, *args: Any, **kwargs: Any) -> None:
236+
"""Emit a warning.
237+
238+
We do a bit of a hack to determine if we are running inside MkDocs or not, and if we are
239+
then we use the MkDocs logger, so warnings are shown in the MkDocs output with special
240+
formatting and also are detected as such when running in *strict* mode.
241+
242+
If we are not running inside MkDocs, then we use the logger for this module.
243+
244+
Args:
245+
msg: Message to emit.
246+
*args: Arguments to format the message with.
247+
**kwargs: Keyword arguments to format the message with.
248+
"""
249+
_warn_logger.warning(msg, *args, **kwargs)
250+
251+
252+
def _get_warn_logger() -> logging.Logger:
253+
"""Get the logger to use for warnings."""
254+
if _is_running_inside_mkdocs():
255+
return logging.getLogger("mkdocs")
256+
return logging.getLogger(__name__)
257+
258+
259+
def _is_running_inside_mkdocs() -> bool:
260+
"""Whether we are running inside MkDocs or not."""
261+
for frame_record in inspect.stack():
262+
frame = frame_record.frame
263+
module = inspect.getmodule(frame)
264+
if module is not None:
265+
# Check if the module's name is associated with MkDocs
266+
if module.__name__.startswith("mkdocs."):
267+
return True
268+
return False
269+
270+
271+
_warn_logger: logging.Logger = _get_warn_logger()
272+
"""The logger to use for warnings."""

0 commit comments

Comments
 (0)