1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237
|
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any, Literal, cast
from markdown import Extension
from markdown.preprocessors import Preprocessor
from auto_pytabs.core import (
Cache,
VersionedCode,
VersionTuple,
get_version_requirements,
version_code,
)
if TYPE_CHECKING:
from markdown import Markdown
RGX_BLOCK_TOKENS = re.compile(r"(.*```py[\w\W]*)|(.*```)")
RGX_PYTABS_DIRECTIVE = re.compile(r"<!-- ?autopytabs: ?(.*)-->")
PyTabDirective = Literal["disable", "enable", "disable-block"]
PYTAB_DIRECTIVES: set[PyTabDirective] = {"disable", "enable", "disable-block"}
def _strip_indentation(lines: list[str]) -> tuple[list[str], str]:
if not lines:
return [], ""
first_line = lines[0]
indent_char = ""
if first_line[0] in [" ", "\t"]:
indent_char = first_line[0]
indent = indent_char * (len(first_line) - len(first_line.lstrip(indent_char)))
if indent:
return [line.split(indent, 1)[1] if line else "" for line in lines], indent
return lines, indent
def _add_indentation(source: str | list[str], indentation: str) -> str:
lines = source.splitlines() if isinstance(source, str) else source
return "\n".join(indentation + line for line in lines)
def _get_pytabs_directive(line: str) -> PyTabDirective | None:
match = RGX_PYTABS_DIRECTIVE.match(line)
if match:
matched_directive = match.group(1).strip()
if matched_directive in PYTAB_DIRECTIVES:
return cast(PyTabDirective, matched_directive)
raise RuntimeError(f"Invalid AutoPytabs directive: {matched_directive!r}")
return None
def _extract_code_blocks(lines: list[str]) -> tuple[list[str], dict[int, list[str]]]:
in_block = False
enabled = True
new_lines: list[str] = []
to_transform = {}
start = 0
for i, line in enumerate(lines):
is_comment_line = False
directive = _get_pytabs_directive(line)
if directive:
is_comment_line = True
if directive == "disable":
enabled = False
elif directive == "enable":
enabled = True
match = RGX_BLOCK_TOKENS.match(line)
if match:
if match.group(1):
in_block = True
start = i
elif match.group(2) and in_block:
in_block = False
block = lines[start : i + 1]
block_directive = _get_pytabs_directive(lines[start - 1])
if enabled and block_directive != "disable-block":
to_transform[len(new_lines)] = block
new_lines.append("")
else:
new_lines.extend(block)
else:
new_lines.append(line)
elif not in_block and not is_comment_line:
new_lines.append(line)
return new_lines, to_transform
def _build_tab(title: str, body: list[str], selected: bool) -> str:
out = f'==={"+" if selected else ""} "{title}"\n'
out += _add_indentation(body, indentation=" ")
out += "\n"
return out
def _build_tabs(
*,
versioned_code: VersionedCode,
head: str,
tail: str,
tab_title_template: str,
default_tab_version: VersionTuple,
reverse_order: bool,
) -> str:
out = []
for version, code in versioned_code.items():
version_string = f"{version[0]}.{version[1]}"
lines = [head, *code.splitlines(), tail]
tab_title = tab_title_template.format(min_version=version_string)
out.append(
_build_tab(
title=tab_title, body=lines, selected=version == default_tab_version
)
)
if reverse_order:
out = reversed(out) # type: ignore[assignment]
return "\n".join(out)
def _convert_block(
*,
block: list[str],
versions: list[VersionTuple],
tab_title_template: str,
cache: Cache | None,
default_tab_strategy: Literal["highest", "lowest"],
reverse_order: bool,
) -> str:
block, indentation = _strip_indentation(block)
head, *code_lines, tail = block
code = "\n".join(code_lines)
versioned_code = version_code(code, versions, cache=cache)
if len(versioned_code) > 1:
versions = list(versioned_code.keys())
default_tab_version = versions[-1 if default_tab_strategy == "highest" else 0]
code = _build_tabs(
versioned_code=versioned_code,
head=head,
tail=tail,
tab_title_template=tab_title_template,
default_tab_version=default_tab_version,
reverse_order=reverse_order,
)
else:
code = "\n".join([head, versioned_code[versions[0]], tail])
code = _add_indentation(code, indentation)
return code
class UpgradePreprocessor(Preprocessor):
def __init__(
self,
*args: Any,
min_version: str,
max_version: str,
tab_title_template: str | None = None,
cache: Cache | None = None,
default_tab_strategy: Literal["highest", "lowest"] = "highest",
reverse_order: bool = False,
**kwargs: Any,
) -> None:
self.min_version = VersionTuple.from_string(min_version)
self.max_version = VersionTuple.from_string(max_version)
self.versions = get_version_requirements(self.min_version, self.max_version)
self.tab_title_template = tab_title_template or "Python {min_version}+"
self.cache = cache
self.default_tab_strategy = default_tab_strategy
self.reverse_order = reverse_order
super().__init__(*args, **kwargs)
def run(self, lines: list[str]) -> list[str]:
new_lines, to_transform = _extract_code_blocks(lines)
output_lines = []
for i, line in enumerate(new_lines):
block_to_transform = to_transform.get(i)
if block_to_transform:
transformed_block = _convert_block(
block=block_to_transform,
versions=self.versions,
tab_title_template=self.tab_title_template,
cache=self.cache,
default_tab_strategy=self.default_tab_strategy,
reverse_order=self.reverse_order,
).splitlines()
output_lines.extend(transformed_block)
else:
output_lines.append(line)
return output_lines
class AutoPyTabsExtension(Extension):
def __init__(self, *args: Any, cache: Cache | None, **kwargs: Any):
self.config = {
"min_version": ["3.7", "minimum version"],
"max_version": ["3.11", "maximum version"],
"tab_title_template": ["", "tab title format-string"],
"default_tab": ["highest", "version tab to preselect"],
"reverse_order": [False, "reverse the order of tabs"],
}
self.cache = cache
super().__init__(*args, **kwargs)
def extendMarkdown(self, md: Markdown) -> None:
"""Register the extension."""
self.md = md
md.registerExtension(self)
config = self.getConfigs()
md.preprocessors.register(
UpgradePreprocessor(
min_version=config["min_version"],
max_version=config["max_version"],
tab_title_template=config["tab_title_template"],
cache=self.cache,
default_tab_strategy=config["default_tab"],
reverse_order=config["reverse_order"],
),
"auto_pytabs",
32,
)
def makeExtension(**kwargs: Any) -> AutoPyTabsExtension:
return AutoPyTabsExtension(**kwargs)
|