Skip to content

Commit 66881ef

Browse files
authored
👌‼️ Allow meta_html/substitutions in docutils (#672)
Refactors `myst_parser/parsers/docutils_.py` slightly, by removing `DOCUTILS_EXCLUDED_ARGS`, and removing the `excluded` argument from `create_myst_settings_spec` and `create_myst_config`.
1 parent ebb5b9f commit 66881ef

File tree

10 files changed

+101
-56
lines changed

10 files changed

+101
-56
lines changed

.github/workflows/tests.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ jobs:
8686
run: python .github/workflows/docutils_setup.py pyproject.toml README.md
8787
- name: Install dependencies
8888
run: |
89-
pip install . pytest~=6.2 pytest-param-files~=0.3.3 pygments docutils==${{ matrix.docutils-version }}
89+
pip install .[linkify,testing-docutils] docutils==${{ matrix.docutils-version }}
9090
- name: ensure sphinx is not installed
9191
run: |
9292
python -c "\
@@ -97,7 +97,7 @@ jobs:
9797
else:
9898
raise AssertionError()"
9999
- name: Run pytest for docutils-only tests
100-
run: pytest tests/test_docutils.py tests/test_renderers/test_fixtures_docutils.py tests/test_renderers/test_include_directive.py
100+
run: pytest tests/test_docutils.py tests/test_renderers/test_fixtures_docutils.py tests/test_renderers/test_include_directive.py tests/test_renderers/test_myst_config.py
101101
- name: Run docutils CLI
102102
run: echo "test" | myst-docutils-html
103103

docs/docutils.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ The CLI commands can also utilise the [`docutils.conf` configuration file](https
4646
[general]
4747
myst-enable-extensions: deflist,linkify
4848
myst-footnote-transition: no
49+
myst-substitutions:
50+
key1: value1
51+
key2: value2
4952
5053
# These entries affect specific HTML output:
5154
[html writers]

myst_parser/_docs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def run(self):
7777
continue
7878

7979
# filter by sphinx options
80-
if "sphinx" in self.options and field.metadata.get("sphinx_exclude"):
80+
if "sphinx" in self.options and field.metadata.get("docutils_only"):
8181
continue
8282

8383
if "extensions" in self.options:

myst_parser/config/dc_validators.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def validate_fields(inst: Any) -> None:
3838

3939
class ValidatorType(Protocol):
4040
def __call__(
41-
self, inst: bytes, field: dc.Field, value: Any, suffix: str = ""
41+
self, inst: Any, field: dc.Field, value: Any, suffix: str = ""
4242
) -> None:
4343
...
4444

myst_parser/config/main.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@
2626
)
2727

2828

29-
def check_extensions(_, __, value):
29+
def check_extensions(_, field: dc.Field, value: Any):
30+
"""Check that the extensions are a list of known strings"""
3031
if not isinstance(value, Iterable):
31-
raise TypeError(f"'enable_extensions' not iterable: {value}")
32+
raise TypeError(f"'{field.name}' not iterable: {value}")
3233
diff = set(value).difference(
3334
[
3435
"amsmath",
@@ -49,16 +50,17 @@ def check_extensions(_, __, value):
4950
]
5051
)
5152
if diff:
52-
raise ValueError(f"'enable_extensions' items not recognised: {diff}")
53+
raise ValueError(f"'{field.name}' items not recognised: {diff}")
5354

5455

55-
def check_sub_delimiters(_, __, value):
56+
def check_sub_delimiters(_, field: dc.Field, value: Any):
57+
"""Check that the sub_delimiters are a tuple of length 2 of strings of length 1"""
5658
if (not isinstance(value, (tuple, list))) or len(value) != 2:
57-
raise TypeError(f"myst_sub_delimiters is not a tuple of length 2: {value}")
59+
raise TypeError(f"'{field.name}' is not a tuple of length 2: {value}")
5860
for delim in value:
5961
if (not isinstance(delim, str)) or len(delim) != 1:
6062
raise TypeError(
61-
f"myst_sub_delimiters does not contain strings of length 1: {value}"
63+
f"'{field.name}' does not contain strings of length 1: {value}"
6264
)
6365

6466

@@ -125,6 +127,7 @@ class MdParserConfig:
125127
deep_iterable(instance_of(str), instance_of((list, tuple)))
126128
),
127129
"help": "Sphinx domain names to search in for link references",
130+
"sphinx_only": True,
128131
},
129132
)
130133

@@ -149,6 +152,7 @@ class MdParserConfig:
149152
metadata={
150153
"validator": optional(in_([1, 2, 3, 4, 5, 6, 7])),
151154
"help": "Heading level depth to assign HTML anchors",
155+
"sphinx_only": True,
152156
},
153157
)
154158

@@ -158,6 +162,7 @@ class MdParserConfig:
158162
"validator": optional(is_callable),
159163
"help": "Function for creating heading anchors",
160164
"global_only": True,
165+
"sphinx_only": True,
161166
},
162167
)
163168

@@ -210,6 +215,7 @@ class MdParserConfig:
210215
"validator": check_sub_delimiters,
211216
"help": "Substitution delimiters",
212217
"extension": "substitutions",
218+
"sphinx_only": True,
213219
},
214220
)
215221

@@ -262,6 +268,7 @@ class MdParserConfig:
262268
"help": "Update sphinx.ext.mathjax configuration to ignore `$` delimiters",
263269
"extension": "dollarmath",
264270
"global_only": True,
271+
"sphinx_only": True,
265272
},
266273
)
267274

@@ -272,6 +279,7 @@ class MdParserConfig:
272279
"help": "MathJax classes to add to math HTML",
273280
"extension": "dollarmath",
274281
"global_only": True,
282+
"sphinx_only": True,
275283
},
276284
)
277285

myst_parser/mdit_to_docutils/base.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1461,16 +1461,12 @@ def html_meta_to_nodes(
14611461
return []
14621462

14631463
try:
1464-
# if sphinx available
1465-
from sphinx.addnodes import meta as meta_cls
1466-
except ImportError:
1467-
try:
1468-
# docutils >= 0.19
1469-
meta_cls = nodes.meta # type: ignore
1470-
except AttributeError:
1471-
from docutils.parsers.rst.directives.html import MetaBody
1464+
meta_cls = nodes.meta
1465+
except AttributeError:
1466+
# docutils-0.17 or older
1467+
from docutils.parsers.rst.directives.html import MetaBody
14721468

1473-
meta_cls = MetaBody.meta # type: ignore
1469+
meta_cls = MetaBody.meta
14741470

14751471
output = []
14761472

myst_parser/parsers/docutils_.py

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from dataclasses import Field
33
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union
44

5+
import yaml
56
from docutils import frontend, nodes
67
from docutils.core import default_description, publish_cmdline
78
from docutils.parsers.rst import Parser as RstParser
@@ -58,32 +59,39 @@ def __bool__(self):
5859
"""Sentinel for arguments not set through docutils.conf."""
5960

6061

61-
DOCUTILS_EXCLUDED_ARGS = (
62-
# docutils.conf can't represent callables
63-
"heading_slug_func",
64-
# docutils.conf can't represent dicts
65-
"html_meta",
66-
"substitutions",
67-
# we can't add substitutions so not needed
68-
"sub_delimiters",
69-
# sphinx only options
70-
"heading_anchors",
71-
"ref_domains",
72-
"update_mathjax",
73-
"mathjax_classes",
74-
)
75-
"""Names of settings that cannot be set in docutils.conf."""
62+
def _create_validate_yaml(field: Field):
63+
"""Create a deserializer/validator for a json setting."""
64+
65+
def _validate_yaml(
66+
setting, value, option_parser, config_parser=None, config_section=None
67+
):
68+
"""Check/normalize a key-value pair setting.
69+
70+
Items delimited by `,`, and key-value pairs delimited by `=`.
71+
"""
72+
try:
73+
output = yaml.safe_load(value)
74+
except Exception:
75+
raise ValueError("Invalid YAML string")
76+
if "validator" in field.metadata:
77+
field.metadata["validator"](None, field, output)
78+
return output
79+
80+
return _validate_yaml
7681

7782

7883
def _attr_to_optparse_option(at: Field, default: Any) -> Tuple[dict, str]:
79-
"""Convert a field into a Docutils optparse options dict."""
84+
"""Convert a field into a Docutils optparse options dict.
85+
86+
:returns: (option_dict, default)
87+
"""
8088
if at.type is int:
81-
return {"metavar": "<int>", "validator": _validate_int}, f"(default: {default})"
89+
return {"metavar": "<int>", "validator": _validate_int}, str(default)
8290
if at.type is bool:
8391
return {
8492
"metavar": "<boolean>",
8593
"validator": frontend.validate_boolean,
86-
}, f"(default: {default})"
94+
}, str(default)
8795
if at.type is str:
8896
return {
8997
"metavar": "<str>",
@@ -96,28 +104,32 @@ def _attr_to_optparse_option(at: Field, default: Any) -> Tuple[dict, str]:
96104
"metavar": f"<{'|'.join(repr(a) for a in args)}>",
97105
"type": "choice",
98106
"choices": args,
99-
}, f"(default: {default!r})"
107+
}, repr(default)
100108
if at.type in (Iterable[str], Sequence[str]):
101109
return {
102110
"metavar": "<comma-delimited>",
103111
"validator": frontend.validate_comma_separated_list,
104-
}, f"(default: '{','.join(default)}')"
112+
}, ",".join(default)
105113
if at.type == Tuple[str, str]:
106114
return {
107115
"metavar": "<str,str>",
108116
"validator": _create_validate_tuple(2),
109-
}, f"(default: '{','.join(default)}')"
117+
}, ",".join(default)
110118
if at.type == Union[int, type(None)]:
111119
return {
112120
"metavar": "<null|int>",
113121
"validator": _validate_int,
114-
}, f"(default: {default})"
122+
}, str(default)
115123
if at.type == Union[Iterable[str], type(None)]:
116-
default_str = ",".join(default) if default else ""
117124
return {
118125
"metavar": "<null|comma-delimited>",
119126
"validator": frontend.validate_comma_separated_list,
120-
}, f"(default: {default_str!r})"
127+
}, ",".join(default) if default else ""
128+
if get_origin(at.type) is dict:
129+
return {
130+
"metavar": "<yaml-dict>",
131+
"validator": _create_validate_yaml(at),
132+
}, str(default) if default else ""
121133
raise AssertionError(
122134
f"Configuration option {at.name} not set up for use in docutils.conf."
123135
)
@@ -133,34 +145,33 @@ def attr_to_optparse_option(
133145
name = f"{prefix}{attribute.name}"
134146
flag = "--" + name.replace("_", "-")
135147
options = {"dest": name, "default": DOCUTILS_UNSET}
136-
at_options, type_str = _attr_to_optparse_option(attribute, default)
148+
at_options, default_str = _attr_to_optparse_option(attribute, default)
137149
options.update(at_options)
138150
help_str = attribute.metadata.get("help", "") if attribute.metadata else ""
139-
return (f"{help_str} {type_str}", [flag], options)
151+
if default_str:
152+
help_str += f" (default: {default_str})"
153+
return (help_str, [flag], options)
140154

141155

142-
def create_myst_settings_spec(
143-
excluded: Sequence[str], config_cls=MdParserConfig, prefix: str = "myst_"
144-
):
156+
def create_myst_settings_spec(config_cls=MdParserConfig, prefix: str = "myst_"):
145157
"""Return a list of Docutils setting for the docutils MyST section."""
146158
defaults = config_cls()
147159
return tuple(
148160
attr_to_optparse_option(at, getattr(defaults, at.name), prefix)
149161
for at in config_cls.get_fields()
150-
if at.name not in excluded
162+
if (not at.metadata.get("sphinx_only", False))
151163
)
152164

153165

154166
def create_myst_config(
155167
settings: frontend.Values,
156-
excluded: Sequence[str],
157168
config_cls=MdParserConfig,
158169
prefix: str = "myst_",
159170
):
160171
"""Create a configuration instance from the given settings."""
161172
values = {}
162173
for attribute in config_cls.get_fields():
163-
if attribute.name in excluded:
174+
if attribute.metadata.get("sphinx_only", False):
164175
continue
165176
setting = f"{prefix}{attribute.name}"
166177
val = getattr(settings, setting, DOCUTILS_UNSET)
@@ -178,7 +189,7 @@ class Parser(RstParser):
178189
settings_spec = (
179190
"MyST options",
180191
None,
181-
create_myst_settings_spec(DOCUTILS_EXCLUDED_ARGS),
192+
create_myst_settings_spec(),
182193
*RstParser.settings_spec,
183194
)
184195
"""Runtime settings specification."""
@@ -209,7 +220,7 @@ def parse(self, inputstring: str, document: nodes.document) -> None:
209220

210221
# create parsing configuration from the global config
211222
try:
212-
config = create_myst_config(document.settings, DOCUTILS_EXCLUDED_ARGS)
223+
config = create_myst_config(document.settings)
213224
except Exception as exc:
214225
error = document.reporter.error(f"Global myst configuration invalid: {exc}")
215226
document.append(error)

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ testing = [
7070
"pytest-param-files~=0.3.4",
7171
"sphinx-pytest",
7272
]
73+
testing-docutils = [
74+
"pygments",
75+
"pytest>=6,<7",
76+
"pytest-param-files~=0.3.4",
77+
]
7378

7479
[project.scripts]
7580
myst-anchors = "myst_parser.cli:print_anchors"

tests/test_renderers/fixtures/myst-config.txt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,27 @@ www.commonmark.org/he<lp
156156
<lp
157157
.
158158

159+
[html_meta] --myst-html-meta='{"keywords": "Sphinx, MyST"}'
160+
.
161+
text
162+
.
163+
<document source="<string>">
164+
<meta content="Sphinx, MyST" name="keywords">
165+
<paragraph>
166+
text
167+
.
168+
169+
[substitutions] --myst-enable-extensions=substitution --myst-substitutions='{"a": "b", "c": "d"}'
170+
.
171+
{{a}} {{c}}
172+
.
173+
<document source="<string>">
174+
<paragraph>
175+
b
176+
177+
d
178+
.
179+
159180
[attrs_inline_span] --myst-enable-extensions=attrs_inline
160181
.
161182
[content]{#id .a .b}

tests/test_renderers/test_myst_config.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from pathlib import Path
55

66
import pytest
7-
from docutils.core import Publisher, publish_doctree
7+
from docutils.core import Publisher, publish_string
88

99
from myst_parser.parsers.docutils_ import Parser
1010

@@ -25,13 +25,14 @@ def test_cmdline(file_params):
2525
f"Failed to parse commandline: {file_params.description}\n{err}"
2626
)
2727
report_stream = StringIO()
28+
settings["output_encoding"] = "unicode"
2829
settings["warning_stream"] = report_stream
29-
doctree = publish_doctree(
30+
output = publish_string(
3031
file_params.content,
3132
parser=Parser(),
33+
writer_name="pseudoxml",
3234
settings_overrides=settings,
3335
)
34-
output = doctree.pformat()
3536
warnings = report_stream.getvalue()
3637
if warnings:
3738
output += "\n" + warnings

0 commit comments

Comments
 (0)