mdformat-py-edu-fr 0.1.5__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.
Potentially problematic release.
This version of mdformat-py-edu-fr might be problematic. Click here for more details.
- mdformat_myst_pef/LICENSE +21 -0
- mdformat_myst_pef/__init__.py +3 -0
- mdformat_myst_pef/_directives.py +139 -0
- mdformat_myst_pef/plugin.py +251 -0
- mdformat_myst_pef/py.typed +1 -0
- mdformat_py_edu_fr/__init__.py +118 -0
- mdformat_py_edu_fr/__main__.py +100 -0
- mdformat_py_edu_fr/format_with_jupytext.py +53 -0
- mdformat_py_edu_fr/jupytext.toml +18 -0
- mdformat_py_edu_fr/util_ruff.py +30 -0
- mdformat_py_edu_fr-0.1.5.dist-info/METADATA +18 -0
- mdformat_py_edu_fr-0.1.5.dist-info/RECORD +14 -0
- mdformat_py_edu_fr-0.1.5.dist-info/WHEEL +4 -0
- mdformat_py_edu_fr-0.1.5.dist-info/entry_points.txt +8 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 Taneli Hukkinen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, MutableMapping, Sequence
|
|
4
|
+
import io
|
|
5
|
+
|
|
6
|
+
from markdown_it import MarkdownIt
|
|
7
|
+
from mdformat.renderer import LOGGER, RenderContext, RenderTreeNode
|
|
8
|
+
import ruamel.yaml
|
|
9
|
+
|
|
10
|
+
yaml = ruamel.yaml.YAML()
|
|
11
|
+
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def longest_consecutive_sequence(seq: str, char: str) -> int:
|
|
15
|
+
"""Return length of the longest consecutive sequence of `char` characters
|
|
16
|
+
in string `seq`."""
|
|
17
|
+
assert len(char) == 1
|
|
18
|
+
longest = 0
|
|
19
|
+
current_streak = 0
|
|
20
|
+
for c in seq:
|
|
21
|
+
if c == char:
|
|
22
|
+
current_streak += 1
|
|
23
|
+
else:
|
|
24
|
+
current_streak = 0
|
|
25
|
+
if current_streak > longest:
|
|
26
|
+
longest = current_streak
|
|
27
|
+
return longest
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fence(node: "RenderTreeNode", context: "RenderContext") -> str:
|
|
31
|
+
"""Render fences (and directives).
|
|
32
|
+
|
|
33
|
+
Copied from upstream `mdformat` core and should be kept up-to-date
|
|
34
|
+
if upstream introduces changes. Note that only two lines are added
|
|
35
|
+
to the upstream implementation, i.e. the condition that calls
|
|
36
|
+
`format_directive_content` function.
|
|
37
|
+
"""
|
|
38
|
+
info_str = node.info.strip()
|
|
39
|
+
lang = info_str.split(maxsplit=1)[0] if info_str else ""
|
|
40
|
+
code_block = node.content
|
|
41
|
+
|
|
42
|
+
# Info strings of backtick code fences can not contain backticks or tildes.
|
|
43
|
+
# If that is the case, we make a tilde code fence instead.
|
|
44
|
+
if "`" in info_str or "~" in info_str:
|
|
45
|
+
fence_char = "~"
|
|
46
|
+
else:
|
|
47
|
+
fence_char = "`"
|
|
48
|
+
|
|
49
|
+
# Format the code block using enabled codeformatter funcs
|
|
50
|
+
if lang in context.options.get("codeformatters", {}):
|
|
51
|
+
fmt_func = context.options["codeformatters"][lang]
|
|
52
|
+
try:
|
|
53
|
+
code_block = fmt_func(code_block, info_str)
|
|
54
|
+
except Exception:
|
|
55
|
+
# Swallow exceptions so that formatter errors (e.g. due to
|
|
56
|
+
# invalid code) do not crash mdformat.
|
|
57
|
+
assert node.map is not None, "A fence token must have `map` attribute set"
|
|
58
|
+
LOGGER.warning(
|
|
59
|
+
f"Failed formatting content of a {lang} code block "
|
|
60
|
+
f"(line {node.map[0] + 1} before formatting)"
|
|
61
|
+
)
|
|
62
|
+
# This "elif" is the *only* thing added to the upstream `fence` implementation!
|
|
63
|
+
elif lang.startswith("{") and lang.endswith("}"):
|
|
64
|
+
code_block = format_directive_content(code_block)
|
|
65
|
+
|
|
66
|
+
# The code block must not include as long or longer sequence of `fence_char`s
|
|
67
|
+
# as the fence string itself
|
|
68
|
+
fence_len = max(3, longest_consecutive_sequence(code_block, fence_char) + 1)
|
|
69
|
+
fence_str = fence_char * fence_len
|
|
70
|
+
|
|
71
|
+
return f"{fence_str}{info_str}\n{code_block}{fence_str}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def format_directive_content(raw_content: str) -> str:
|
|
75
|
+
parse_result = parse_opts_and_content(raw_content)
|
|
76
|
+
if not parse_result:
|
|
77
|
+
return raw_content
|
|
78
|
+
unformatted_yaml, content = parse_result
|
|
79
|
+
dump_stream = io.StringIO()
|
|
80
|
+
try:
|
|
81
|
+
parsed = yaml.load(unformatted_yaml)
|
|
82
|
+
yaml.dump(parsed, stream=dump_stream)
|
|
83
|
+
except ruamel.yaml.YAMLError:
|
|
84
|
+
LOGGER.warning("Invalid YAML in MyST directive options.")
|
|
85
|
+
return raw_content
|
|
86
|
+
formatted_yaml = dump_stream.getvalue()
|
|
87
|
+
|
|
88
|
+
# Remove the YAML closing tag if added by `ruamel.yaml`
|
|
89
|
+
if formatted_yaml.endswith("\n...\n"):
|
|
90
|
+
formatted_yaml = formatted_yaml[:-4]
|
|
91
|
+
|
|
92
|
+
# Convert empty YAML to most concise form
|
|
93
|
+
if formatted_yaml == "null\n":
|
|
94
|
+
formatted_yaml = ""
|
|
95
|
+
|
|
96
|
+
formatted = "---\n" + formatted_yaml + "---\n"
|
|
97
|
+
if content:
|
|
98
|
+
formatted += content + "\n"
|
|
99
|
+
return formatted
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_opts_and_content(raw_content: str) -> tuple[str, str] | None:
|
|
103
|
+
if not raw_content:
|
|
104
|
+
return None
|
|
105
|
+
lines = raw_content.splitlines()
|
|
106
|
+
line = lines.pop(0)
|
|
107
|
+
yaml_lines = []
|
|
108
|
+
if all(c == "-" for c in line) and len(line) >= 3:
|
|
109
|
+
while lines:
|
|
110
|
+
line = lines.pop(0)
|
|
111
|
+
if all(c == "-" for c in line) and len(line) >= 3:
|
|
112
|
+
break
|
|
113
|
+
yaml_lines.append(line)
|
|
114
|
+
elif line.lstrip().startswith(":"):
|
|
115
|
+
yaml_lines.append(line.lstrip()[1:])
|
|
116
|
+
while lines:
|
|
117
|
+
if not lines[0].lstrip().startswith(":"):
|
|
118
|
+
break
|
|
119
|
+
line = lines.pop(0).lstrip()[1:]
|
|
120
|
+
yaml_lines.append(line)
|
|
121
|
+
else:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
first_line_is_empty_but_second_line_isnt = (
|
|
125
|
+
len(lines) >= 2 and not lines[0].strip() and lines[1].strip()
|
|
126
|
+
)
|
|
127
|
+
exactly_one_empty_line = len(lines) == 1 and not lines[0].strip()
|
|
128
|
+
if first_line_is_empty_but_second_line_isnt or exactly_one_empty_line:
|
|
129
|
+
lines.pop(0)
|
|
130
|
+
|
|
131
|
+
unformatted_yaml = "\n".join(yaml_lines)
|
|
132
|
+
content = "\n".join(lines)
|
|
133
|
+
return unformatted_yaml, content
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def render_fence_html(
|
|
137
|
+
self: MarkdownIt, tokens: Sequence, idx: int, options: Mapping, env: MutableMapping
|
|
138
|
+
) -> str:
|
|
139
|
+
return ""
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import textwrap
|
|
5
|
+
|
|
6
|
+
from markdown_it import MarkdownIt
|
|
7
|
+
import mdformat.plugins
|
|
8
|
+
from mdformat.renderer import RenderContext, RenderTreeNode
|
|
9
|
+
from mdit_py_plugins.dollarmath import dollarmath_plugin
|
|
10
|
+
from mdit_py_plugins.myst_blocks import myst_block_plugin
|
|
11
|
+
from mdit_py_plugins.myst_role import myst_role_plugin
|
|
12
|
+
from mdit_py_plugins.container import container_plugin
|
|
13
|
+
|
|
14
|
+
from mdformat_myst_pef._directives import fence, render_fence_html
|
|
15
|
+
|
|
16
|
+
_TARGET_PATTERN = re.compile(r"^\s*\(.+\)=\s*$")
|
|
17
|
+
_ROLE_NAME_PATTERN = re.compile(r"({[a-zA-Z0-9_\-+:]+})")
|
|
18
|
+
_YAML_HEADER_PATTERN = re.compile(r"(?m)(^:\w+: .*$\n?)+|^---$\n(?s:.).*\n---\n")
|
|
19
|
+
|
|
20
|
+
container_names = [
|
|
21
|
+
"admonition",
|
|
22
|
+
"attention",
|
|
23
|
+
"caution",
|
|
24
|
+
"danger",
|
|
25
|
+
"div",
|
|
26
|
+
"dropdown",
|
|
27
|
+
"embed",
|
|
28
|
+
"error",
|
|
29
|
+
"exercise",
|
|
30
|
+
"exercise-end",
|
|
31
|
+
"exercise-start",
|
|
32
|
+
"figure",
|
|
33
|
+
"glossary",
|
|
34
|
+
"grid",
|
|
35
|
+
"grid-item",
|
|
36
|
+
"grid-item-card",
|
|
37
|
+
"hint",
|
|
38
|
+
"image",
|
|
39
|
+
"important",
|
|
40
|
+
"include",
|
|
41
|
+
"index",
|
|
42
|
+
"literal-include",
|
|
43
|
+
"margin",
|
|
44
|
+
"math",
|
|
45
|
+
"note",
|
|
46
|
+
"prf:algorithm",
|
|
47
|
+
"prf:assumption",
|
|
48
|
+
"prf:axiom",
|
|
49
|
+
"prf:conjecture",
|
|
50
|
+
"prf:corollary",
|
|
51
|
+
"prf:criterion",
|
|
52
|
+
"prf:definition",
|
|
53
|
+
"prf:example",
|
|
54
|
+
"prf:lemma",
|
|
55
|
+
"prf:observation",
|
|
56
|
+
"prf:proof",
|
|
57
|
+
"prf:property",
|
|
58
|
+
"prf:proposition",
|
|
59
|
+
"prf:remark",
|
|
60
|
+
"prf:theorem",
|
|
61
|
+
"seealso",
|
|
62
|
+
"show-index",
|
|
63
|
+
"sidebar",
|
|
64
|
+
"solution",
|
|
65
|
+
"solution-end",
|
|
66
|
+
"solution-start",
|
|
67
|
+
"span",
|
|
68
|
+
"tab-item",
|
|
69
|
+
"tab-set",
|
|
70
|
+
"table",
|
|
71
|
+
"tip",
|
|
72
|
+
"todo",
|
|
73
|
+
"topics",
|
|
74
|
+
"warning",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def update_mdit(mdit: MarkdownIt) -> None:
|
|
79
|
+
# Enable mdformat-tables plugin
|
|
80
|
+
tables_plugin = mdformat.plugins.PARSER_EXTENSIONS["tables"]
|
|
81
|
+
if tables_plugin not in mdit.options["parser_extension"]:
|
|
82
|
+
mdit.options["parser_extension"].append(tables_plugin)
|
|
83
|
+
tables_plugin.update_mdit(mdit)
|
|
84
|
+
|
|
85
|
+
# Enable mdformat-frontmatter plugin
|
|
86
|
+
frontmatter_plugin = mdformat.plugins.PARSER_EXTENSIONS["frontmatter"]
|
|
87
|
+
if frontmatter_plugin not in mdit.options["parser_extension"]:
|
|
88
|
+
mdit.options["parser_extension"].append(frontmatter_plugin)
|
|
89
|
+
frontmatter_plugin.update_mdit(mdit)
|
|
90
|
+
|
|
91
|
+
# Enable mdformat-footnote plugin
|
|
92
|
+
footnote_plugin = mdformat.plugins.PARSER_EXTENSIONS["footnote"]
|
|
93
|
+
if footnote_plugin not in mdit.options["parser_extension"]:
|
|
94
|
+
mdit.options["parser_extension"].append(footnote_plugin)
|
|
95
|
+
footnote_plugin.update_mdit(mdit)
|
|
96
|
+
|
|
97
|
+
# Enable MyST role markdown-it extension
|
|
98
|
+
mdit.use(myst_role_plugin)
|
|
99
|
+
|
|
100
|
+
# Enable MyST block markdown-it extension (including "LineComment",
|
|
101
|
+
# "BlockBreak" and "Target" syntaxes)
|
|
102
|
+
mdit.use(myst_block_plugin)
|
|
103
|
+
|
|
104
|
+
# Enable dollarmath markdown-it extension
|
|
105
|
+
mdit.use(dollarmath_plugin)
|
|
106
|
+
|
|
107
|
+
# Trick `mdformat`s AST validation by removing HTML rendering of code
|
|
108
|
+
# blocks and fences. Directives are parsed as code fences and we
|
|
109
|
+
# modify them in ways that don't break MyST AST but do break
|
|
110
|
+
# CommonMark AST, so we need to do this to make validation pass.
|
|
111
|
+
mdit.add_render_rule("fence", render_fence_html)
|
|
112
|
+
mdit.add_render_rule("code_block", render_fence_html)
|
|
113
|
+
|
|
114
|
+
for name in container_names:
|
|
115
|
+
container_plugin(mdit, name="{" + name + "}", marker=":")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def container_renderer(
|
|
119
|
+
node: RenderTreeNode, context: RenderContext, *args, **kwargs
|
|
120
|
+
) -> str:
|
|
121
|
+
children = node.children
|
|
122
|
+
paragraphs = []
|
|
123
|
+
if children:
|
|
124
|
+
# Look at the tokens forming the first paragraph and see if
|
|
125
|
+
# they form a YAML header. This could be stricter: there
|
|
126
|
+
# should be exactly three tokens: paragraph open, YAML
|
|
127
|
+
# header, paragraph end.
|
|
128
|
+
tokens = children[0].to_tokens()
|
|
129
|
+
if all(
|
|
130
|
+
token.type in {"paragraph_open", "paragraph_close"}
|
|
131
|
+
or _YAML_HEADER_PATTERN.fullmatch(token.content)
|
|
132
|
+
for token in tokens
|
|
133
|
+
):
|
|
134
|
+
paragraphs.append(
|
|
135
|
+
"\n".join(token.content.strip() for token in tokens if token.content)
|
|
136
|
+
)
|
|
137
|
+
# and skip that first paragraph
|
|
138
|
+
children = children[1:]
|
|
139
|
+
|
|
140
|
+
paragraphs.extend(child.render(context) for child in children)
|
|
141
|
+
|
|
142
|
+
return node.markup + node.info + "\n" + "\n\n".join(paragraphs) + "\n" + node.markup
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _role_renderer(node: RenderTreeNode, context: RenderContext) -> str:
|
|
146
|
+
role_name = "{" + node.meta["name"] + "}"
|
|
147
|
+
role_content = f"`{node.content}`"
|
|
148
|
+
return role_name + role_content
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _comment_renderer(node: RenderTreeNode, context: RenderContext) -> str:
|
|
152
|
+
return "%" + node.content.replace("\n", "\n%")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _blockbreak_renderer(node: RenderTreeNode, context: RenderContext) -> str:
|
|
156
|
+
text = "+++"
|
|
157
|
+
if node.content:
|
|
158
|
+
text += f" {node.content}"
|
|
159
|
+
return text
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _target_renderer(node: RenderTreeNode, context: RenderContext) -> str:
|
|
163
|
+
return f"({node.content})="
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _math_inline_renderer(node: RenderTreeNode, context: RenderContext) -> str:
|
|
167
|
+
return f"${node.content}$"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _math_block_renderer(node: RenderTreeNode, context: RenderContext) -> str:
|
|
171
|
+
indent_width = context.env.get("indent_width", 0)
|
|
172
|
+
if indent_width > 0:
|
|
173
|
+
return f"$${textwrap.dedent(node.content)}$$"
|
|
174
|
+
return f"$${node.content}$$"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _math_block_label_renderer(node: RenderTreeNode, context: RenderContext) -> str:
|
|
178
|
+
return f"{_math_block_renderer(node, context)} ({node.info})"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _math_block_safe_blockquote_renderer(
|
|
182
|
+
node: RenderTreeNode, context: RenderContext
|
|
183
|
+
) -> str:
|
|
184
|
+
marker = "> "
|
|
185
|
+
with context.indented(len(marker)):
|
|
186
|
+
lines = []
|
|
187
|
+
for i, child in enumerate(node.children):
|
|
188
|
+
if child.type in ("math_block", "math_block_label"):
|
|
189
|
+
lines.append(child.render(context))
|
|
190
|
+
else:
|
|
191
|
+
lines.extend(child.render(context).splitlines())
|
|
192
|
+
if i < (len(node.children) - 1):
|
|
193
|
+
lines.append("")
|
|
194
|
+
if not lines:
|
|
195
|
+
return ">"
|
|
196
|
+
quoted_lines = (f"{marker}{line}" if line else ">" for line in lines)
|
|
197
|
+
quoted_str = "\n".join(quoted_lines)
|
|
198
|
+
return quoted_str
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _render_children(node: RenderTreeNode, context: RenderContext) -> str:
|
|
202
|
+
return "\n\n".join(child.render(context) for child in node.children)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _escape_paragraph(text: str, node: RenderTreeNode, context: RenderContext) -> str:
|
|
206
|
+
lines = text.split("\n")
|
|
207
|
+
|
|
208
|
+
for i in range(len(lines)):
|
|
209
|
+
# Three or more "+" chars are interpreted as a block break. Escape them.
|
|
210
|
+
space_removed = lines[i].replace(" ", "")
|
|
211
|
+
if space_removed.startswith("+++"):
|
|
212
|
+
lines[i] = lines[i].replace("+", "\\+", 1)
|
|
213
|
+
|
|
214
|
+
# A line starting with "%" is a comment. Escape.
|
|
215
|
+
if lines[i].startswith("%"):
|
|
216
|
+
lines[i] = f"\\{lines[i]}"
|
|
217
|
+
|
|
218
|
+
# Escape lines that look like targets
|
|
219
|
+
if _TARGET_PATTERN.search(lines[i]):
|
|
220
|
+
lines[i] = lines[i].replace("(", "\\(", 1)
|
|
221
|
+
|
|
222
|
+
return "\n".join(lines)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _escape_text(text: str, node: RenderTreeNode, context: RenderContext) -> str:
|
|
226
|
+
# Escape MyST role names
|
|
227
|
+
text = _ROLE_NAME_PATTERN.sub(r"\\\1", text)
|
|
228
|
+
|
|
229
|
+
# Escape inline and block dollarmath
|
|
230
|
+
text = text.replace("$", "\\$")
|
|
231
|
+
|
|
232
|
+
return text
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
RENDERERS = {
|
|
236
|
+
"blockquote": _math_block_safe_blockquote_renderer,
|
|
237
|
+
"myst_role": _role_renderer,
|
|
238
|
+
"myst_line_comment": _comment_renderer,
|
|
239
|
+
"myst_block_break": _blockbreak_renderer,
|
|
240
|
+
"myst_target": _target_renderer,
|
|
241
|
+
"math_inline": _math_inline_renderer,
|
|
242
|
+
"math_block_label": _math_block_label_renderer,
|
|
243
|
+
"math_block": _math_block_renderer,
|
|
244
|
+
"fence": fence,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
for name in container_names:
|
|
249
|
+
RENDERERS["container_{" + name + "}"] = container_renderer
|
|
250
|
+
|
|
251
|
+
POSTPROCESSORS = {"paragraph": _escape_paragraph, "text": _escape_text}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
import mdformat
|
|
6
|
+
|
|
7
|
+
from .format_with_jupytext import format_str
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from importlib.metadata import version
|
|
11
|
+
|
|
12
|
+
__version__ = version("mdformat-py-edu-fr")
|
|
13
|
+
except Exception:
|
|
14
|
+
__version__ = "unknown"
|
|
15
|
+
|
|
16
|
+
from mdformat._conf import DEFAULT_OPTS
|
|
17
|
+
|
|
18
|
+
options = DEFAULT_OPTS.copy()
|
|
19
|
+
options.update({"number": True, "wrap": 89, "end_of_line": "lf"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _match_patterns(path: Path, patterns: List[str]) -> bool:
|
|
23
|
+
"""
|
|
24
|
+
Check if a path matches any of the given glob patterns.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
path: Path to check
|
|
28
|
+
patterns: List of glob patterns
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
True if path matches any pattern, False otherwise
|
|
32
|
+
"""
|
|
33
|
+
for pattern in patterns:
|
|
34
|
+
if path.match(pattern) or path.full_match(pattern):
|
|
35
|
+
return True
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def collect_markdown_files(
|
|
40
|
+
paths: List[Path], exclude_patterns: List[str] = None
|
|
41
|
+
) -> List[Path]:
|
|
42
|
+
"""
|
|
43
|
+
Collect all Markdown files from the given paths.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
paths: List of file or directory paths
|
|
47
|
+
exclude_patterns: List of glob patterns to exclude
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
List of Path objects pointing to Markdown files
|
|
51
|
+
"""
|
|
52
|
+
if exclude_patterns is None:
|
|
53
|
+
exclude_patterns = []
|
|
54
|
+
|
|
55
|
+
markdown_files = []
|
|
56
|
+
|
|
57
|
+
for path in paths:
|
|
58
|
+
if not path.exists():
|
|
59
|
+
print(f"Error: Path does not exist: {path}", file=sys.stderr)
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
if path.is_file():
|
|
63
|
+
if path.suffix in {".md", ".markdown"}:
|
|
64
|
+
# Check if file matches any exclude pattern
|
|
65
|
+
if not _match_patterns(path, exclude_patterns):
|
|
66
|
+
markdown_files.append(path)
|
|
67
|
+
else:
|
|
68
|
+
print(f"Warning: Skipping non-Markdown file: {path}", file=sys.stderr)
|
|
69
|
+
elif path.is_dir():
|
|
70
|
+
# Recursively find all Markdown files
|
|
71
|
+
for md_file in path.rglob("*.md"):
|
|
72
|
+
if not _match_patterns(md_file, exclude_patterns):
|
|
73
|
+
markdown_files.append(md_file)
|
|
74
|
+
for md_file in path.rglob("*.markdown"):
|
|
75
|
+
if not _match_patterns(md_file, exclude_patterns):
|
|
76
|
+
markdown_files.append(md_file)
|
|
77
|
+
else:
|
|
78
|
+
print(f"Warning: Skipping special file: {path}", file=sys.stderr)
|
|
79
|
+
|
|
80
|
+
return sorted(set(markdown_files))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def format_file(filepath: Path, check: bool = False, verbose: bool = False) -> bool:
|
|
84
|
+
"""
|
|
85
|
+
Format a single Markdown file.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
filepath: Path to the file to format
|
|
89
|
+
check: If True, only check formatting without modifying
|
|
90
|
+
verbose: If True, print detailed information
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
True if file is properly formatted (or was formatted), False otherwise
|
|
94
|
+
"""
|
|
95
|
+
if verbose:
|
|
96
|
+
print(f"Processing: {filepath}")
|
|
97
|
+
|
|
98
|
+
original_str = filepath.read_text()
|
|
99
|
+
|
|
100
|
+
enabled_parserplugins = mdformat.plugins.PARSER_EXTENSIONS
|
|
101
|
+
|
|
102
|
+
formatted_str = mdformat.text(
|
|
103
|
+
original_str,
|
|
104
|
+
options=options,
|
|
105
|
+
extensions=enabled_parserplugins,
|
|
106
|
+
_filename=str(filepath),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
formatted_str = format_str(formatted_str)
|
|
110
|
+
|
|
111
|
+
formatted = formatted_str == original_str
|
|
112
|
+
|
|
113
|
+
if check or formatted:
|
|
114
|
+
return formatted
|
|
115
|
+
|
|
116
|
+
filepath.write_text(formatted_str)
|
|
117
|
+
print(f"{filepath} reformatted")
|
|
118
|
+
return formatted
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Markdown formatter for py-edu-fr with mdformat, mdformat-myst, and jupytext support.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__, format_file, collect_markdown_files
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_parser() -> argparse.ArgumentParser:
|
|
14
|
+
"""Create and configure the argument parser."""
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="mdformat-py-edu-fr",
|
|
17
|
+
description="Format Markdown files for py-edu-fr project",
|
|
18
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--version",
|
|
23
|
+
action="version",
|
|
24
|
+
version=f"%(prog)s {__version__}",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"paths",
|
|
29
|
+
nargs="*",
|
|
30
|
+
type=Path,
|
|
31
|
+
help="Files or directories to format (if omitted, reads from stdin)",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--check",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="Check if files are formatted without modifying them (exit code 1 if changes needed)",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--exclude",
|
|
42
|
+
action="append",
|
|
43
|
+
default=[],
|
|
44
|
+
metavar="PATTERN",
|
|
45
|
+
help="Glob pattern to exclude files/directories (can be specified multiple times)",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--verbose",
|
|
50
|
+
"-v",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="Print detailed information about processing",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return parser
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main() -> int:
|
|
59
|
+
"""
|
|
60
|
+
Main entrypoint for the mdformat-py-edu-fr command.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Exit code (0 for success, 1 for failure)
|
|
64
|
+
"""
|
|
65
|
+
parser = get_parser()
|
|
66
|
+
args = parser.parse_args()
|
|
67
|
+
|
|
68
|
+
# Handle stdin if no paths provided
|
|
69
|
+
if not args.paths:
|
|
70
|
+
print("Error: Reading from stdin not yet implemented", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
|
|
73
|
+
# Collect all Markdown files
|
|
74
|
+
markdown_files = collect_markdown_files(args.paths, args.exclude)
|
|
75
|
+
|
|
76
|
+
if not markdown_files:
|
|
77
|
+
print("No Markdown files found", file=sys.stderr)
|
|
78
|
+
return 1
|
|
79
|
+
|
|
80
|
+
if args.verbose:
|
|
81
|
+
print(f"Found {len(markdown_files)} Markdown file(s)")
|
|
82
|
+
|
|
83
|
+
# Process each file
|
|
84
|
+
all_formatted = True
|
|
85
|
+
for filepath in markdown_files:
|
|
86
|
+
formatted = format_file(filepath, check=args.check, verbose=args.verbose)
|
|
87
|
+
if not formatted:
|
|
88
|
+
all_formatted = False
|
|
89
|
+
if args.check:
|
|
90
|
+
print(f"Would reformat: {filepath}")
|
|
91
|
+
|
|
92
|
+
# Return appropriate exit code
|
|
93
|
+
if args.check and not all_formatted:
|
|
94
|
+
return 1
|
|
95
|
+
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
sys.exit(main())
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import jupytext
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from mdformat_py_edu_fr.util_ruff import format_code_with_ruff, RuffFormattingError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
LABEL_PATTERN = re.compile("(?m)(^\\s*\\(\\w+\\)=)(\r?\n)(\\s*\r?\n)")
|
|
10
|
+
|
|
11
|
+
path_config_file = Path(__file__).absolute().parent / "jupytext.toml"
|
|
12
|
+
config = jupytext.config.load_jupytext_configuration_file(str(path_config_file))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def format_str(text: str) -> str:
|
|
16
|
+
"""Format the code of a notebook code using jupytext"""
|
|
17
|
+
|
|
18
|
+
notebook = jupytext.reads(text, fmt="md:myst")
|
|
19
|
+
if "kernelspec" not in notebook.metadata:
|
|
20
|
+
return text
|
|
21
|
+
language = notebook["metadata"]["kernelspec"]["language"]
|
|
22
|
+
if "learning" in notebook.metadata:
|
|
23
|
+
# Fix common errors in learning metadata
|
|
24
|
+
learning = notebook.metadata["learning"]
|
|
25
|
+
for a in ["prerequisites", "objectives"]:
|
|
26
|
+
# Fix singular instead of plural
|
|
27
|
+
singular = a[:-1]
|
|
28
|
+
if singular in learning:
|
|
29
|
+
learning[a] = learning[singular]
|
|
30
|
+
del learning[singular]
|
|
31
|
+
|
|
32
|
+
# Fix string instead of list of string
|
|
33
|
+
for b in ["discover", "remember", "understand", "apply"]:
|
|
34
|
+
if a in learning and isinstance(learning[a].get(b), str):
|
|
35
|
+
learning[a][b] = [s.strip() for s in learning[a][b].split(",")]
|
|
36
|
+
|
|
37
|
+
for cell in notebook.cells:
|
|
38
|
+
if language == "python" and cell["cell_type"] == "code":
|
|
39
|
+
cell.source = format_code_with_ruff(cell["source"].strip())
|
|
40
|
+
if (
|
|
41
|
+
cell.metadata is not None
|
|
42
|
+
and "tags" in cell.metadata
|
|
43
|
+
and cell.metadata["tags"] == []
|
|
44
|
+
):
|
|
45
|
+
del cell.metadata["tags"]
|
|
46
|
+
# Workaround mdformat-myst to remove empty lines between label and subsequent item
|
|
47
|
+
if cell.cell_type == "markdown":
|
|
48
|
+
cell.source = re.sub(LABEL_PATTERN, r"\1\2", cell.source)
|
|
49
|
+
|
|
50
|
+
result = jupytext.writes(notebook, fmt="md:myst", config=config)
|
|
51
|
+
if language == "python":
|
|
52
|
+
result = result.replace("```{code-cell} ipython3", "```{code-cell}")
|
|
53
|
+
return result
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# This file configures which notebook or cell metadata is kept or
|
|
2
|
+
# discarded upon saving a file with jupytext / jupyter lab.
|
|
3
|
+
#
|
|
4
|
+
# - discard jupytext.text_representation.jupytext_version: including
|
|
5
|
+
# the jupytext version in the metadata adds no value and causes
|
|
6
|
+
# conflicts.
|
|
7
|
+
#
|
|
8
|
+
# - keep learning metadata: this is used in certain notebooks for
|
|
9
|
+
# specifying learning objectives, etc.
|
|
10
|
+
#
|
|
11
|
+
# - discard cell metadata that describes the transient state of the
|
|
12
|
+
# notebook
|
|
13
|
+
#
|
|
14
|
+
# - discard editable / deletable metadata: these are to be set
|
|
15
|
+
# automatically upon preparing the student version of the notebooks
|
|
16
|
+
|
|
17
|
+
notebook_metadata_filter="kernelspec,jupytext,exports,math,learning,-jupytext.text_representation.jupytext_version"
|
|
18
|
+
cell_metadata_filter="all,-autoscroll,-collapsed,-scrolled,-trusted,-ExecuteTime,-jp-MarkdownHeadingCollapsed,-user_expressions,-editable,-deletable"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RuffFormattingError(Exception):
|
|
6
|
+
"""Exception raised when ruff formatting fails."""
|
|
7
|
+
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_code_with_ruff(code: str) -> str:
|
|
12
|
+
"""Format Python code using ruff.
|
|
13
|
+
|
|
14
|
+
Returns formatted code.
|
|
15
|
+
|
|
16
|
+
Raises:
|
|
17
|
+
RuffFormattingError: If ruff formatting fails.
|
|
18
|
+
"""
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
["ruff", "format", "-"],
|
|
21
|
+
input=code.encode("utf-8"),
|
|
22
|
+
capture_output=True,
|
|
23
|
+
check=False,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if result.returncode != 0:
|
|
27
|
+
error_msg = result.stderr.decode("utf-8")
|
|
28
|
+
raise RuffFormattingError(f"ruff formatting failed: {error_msg}")
|
|
29
|
+
|
|
30
|
+
return result.stdout.decode("utf-8")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: mdformat-py-edu-fr
|
|
3
|
+
Version: 0.1.5
|
|
4
|
+
Summary: Tiny wrapper around mdformat for the py-edu-fr project
|
|
5
|
+
Author-Email: Pierre Augier <pierre.augier@univ-grenoble-alpes.fr>
|
|
6
|
+
License: BSD-3-Clause
|
|
7
|
+
Requires-Python: >=3.13
|
|
8
|
+
Requires-Dist: jupytext
|
|
9
|
+
Requires-Dist: mdformat>=0.7.0
|
|
10
|
+
Requires-Dist: mdit-py-plugins>=0.3.0
|
|
11
|
+
Requires-Dist: mdformat-frontmatter>=0.3.2
|
|
12
|
+
Requires-Dist: mdformat-footnote>=0.1.1
|
|
13
|
+
Requires-Dist: mdformat-gfm>=1.0.0
|
|
14
|
+
Requires-Dist: ruamel.yaml>=0.16.0
|
|
15
|
+
Requires-Dist: ruff
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Tiny wrapper around mdformat for the py-edu-fr project
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
mdformat_myst_pef/LICENSE,sha256=uAgWsNUwuKzLTCIReDeQmEpuO2GSLCte6S8zcqsnQv4,1072
|
|
2
|
+
mdformat_myst_pef/__init__.py,sha256=aYAObHCbEiPg3WwadhJ_TB7SCk8bGlTTmNMO2tj1Q40,69
|
|
3
|
+
mdformat_myst_pef/_directives.py,sha256=gr9XXHeE1OpEPrUKZdy2S9iI4aQVgX4kO2UyhJlmSEk,4793
|
|
4
|
+
mdformat_myst_pef/plugin.py,sha256=fFqNG4UV_2aNSDYnJxbgV541K3qf7jQ53VMBSB1ktCU,7733
|
|
5
|
+
mdformat_myst_pef/py.typed,sha256=8PjyZ1aVoQpRVvt71muvuq5qE-jTFZkK-GLHkhdebmc,26
|
|
6
|
+
mdformat_py_edu_fr-0.1.5.dist-info/METADATA,sha256=UFPdefvVl5vYXSsKREkgKrreg3XOaTF8NqpAFWRQ1q4,603
|
|
7
|
+
mdformat_py_edu_fr-0.1.5.dist-info/WHEEL,sha256=RSz6kXtM6TF3IJK4O7lm4V0QzFJUHF9t5pxUi4i5D34,104
|
|
8
|
+
mdformat_py_edu_fr-0.1.5.dist-info/entry_points.txt,sha256=dNsCwKXSNYQUbo19idpAR7WOLX6XiklUmMmb_Tfj7gs,149
|
|
9
|
+
mdformat_py_edu_fr/__init__.py,sha256=a87wI51gBRuFn1_IauF42noia0IE4t0kmOiJRIWVVl4,3312
|
|
10
|
+
mdformat_py_edu_fr/__main__.py,sha256=SI9uVJaNxyo8MVoAzb2pJWdXszT-zdWl6afekldFn2c,2509
|
|
11
|
+
mdformat_py_edu_fr/format_with_jupytext.py,sha256=GikvHM--0uO-bFKHWgzOExzhwjOluxDEc5lLVP6xUFU,2056
|
|
12
|
+
mdformat_py_edu_fr/jupytext.toml,sha256=ZQwweaKSvIGXWw-lEwJfnzTPSbEqiGQjNmPBxsQVxmQ,872
|
|
13
|
+
mdformat_py_edu_fr/util_ruff.py,sha256=SgzJOvSD0UWz71mHfBu9_EYdV3JFZaFqRxTfMV2_GoM,676
|
|
14
|
+
mdformat_py_edu_fr-0.1.5.dist-info/RECORD,,
|