mdformat-py-edu-fr 0.1.6__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.
@@ -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,3 @@
1
+ """Mdformat plugin for MyST compatibility."""
2
+
3
+ __version__ = "0.2.2"
@@ -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,63 @@
1
+ import jupytext
2
+ import re
3
+ import sys
4
+
5
+ from pathlib import Path
6
+
7
+ from mdformat_py_edu_fr.util_ruff import format_code_with_ruff, RuffFormattingError
8
+
9
+
10
+ LABEL_PATTERN = re.compile("(?m)(^\\s*\\(\\w+\\)=)(\r?\n)(\\s*\r?\n)")
11
+
12
+ path_config_file = Path(__file__).absolute().parent / "jupytext.toml"
13
+ config = jupytext.config.load_jupytext_configuration_file(str(path_config_file))
14
+
15
+
16
+ def format_str(text: str) -> str:
17
+ """Format the code of a notebook code using jupytext"""
18
+
19
+ notebook = jupytext.reads(text, fmt="md:myst")
20
+ if "kernelspec" not in notebook.metadata:
21
+ return text
22
+ language = notebook["metadata"]["kernelspec"]["language"]
23
+ if "learning" in notebook.metadata:
24
+ # Fix common errors in learning metadata
25
+ learning = notebook.metadata["learning"]
26
+ for a in ["prerequisites", "objectives"]:
27
+ # Fix singular instead of plural
28
+ singular = a[:-1]
29
+ if singular in learning:
30
+ learning[a] = learning[singular]
31
+ del learning[singular]
32
+
33
+ # Fix string instead of list of string
34
+ for b in ["discover", "remember", "understand", "apply"]:
35
+ if a in learning and isinstance(learning[a].get(b), str):
36
+ learning[a][b] = [s.strip() for s in learning[a][b].split(",")]
37
+
38
+ for cell in notebook.cells:
39
+ if language == "python" and cell["cell_type"] == "code":
40
+ try:
41
+ cell.source = format_code_with_ruff(cell["source"].strip())
42
+ except RuffFormattingError as exc:
43
+ print(
44
+ f"Ruff formatting failed for cell source:\n{cell.source}\n",
45
+ exc,
46
+ file=sys.stderr,
47
+ )
48
+ sys.exit(1)
49
+
50
+ if (
51
+ cell.metadata is not None
52
+ and "tags" in cell.metadata
53
+ and cell.metadata["tags"] == []
54
+ ):
55
+ del cell.metadata["tags"]
56
+ # Workaround to remove empty lines between label and subsequent item
57
+ if cell.cell_type == "markdown":
58
+ cell.source = re.sub(LABEL_PATTERN, r"\1\2", cell.source)
59
+
60
+ result = jupytext.writes(notebook, fmt="md:myst", config=config)
61
+ if language == "python":
62
+ result = result.replace("```{code-cell} ipython3", "```{code-cell}")
63
+ 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(error_msg)
29
+
30
+ return result.stdout.decode("utf-8")
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: mdformat-py-edu-fr
3
+ Version: 0.1.6
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-Expression: BSD-3-Clause
7
+ License-File: LICENSE.txt
8
+ Requires-Python: >=3.13
9
+ Requires-Dist: jupytext
10
+ Requires-Dist: mdformat>=0.7.0
11
+ Requires-Dist: mdit-py-plugins>=0.3.0
12
+ Requires-Dist: mdformat-frontmatter>=0.3.2
13
+ Requires-Dist: mdformat-footnote>=0.1.1
14
+ Requires-Dist: mdformat-gfm>=1.0.0
15
+ Requires-Dist: ruamel.yaml>=0.16.0
16
+ Requires-Dist: ruff
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Tiny wrapper around mdformat for the py-edu-fr project
20
+
21
+ We needed a specific markdown formatter for the
22
+ [py-edu-fr project](https://python-cnrs.netlify.app/edu).
23
+
24
+ mdformat-py-edu-fr is based on mdformat, mdformat-myst and Jupytext.
25
+
26
+ ```
27
+ $ mdformat-py-edu-fr -h
28
+ usage: mdformat-py-edu-fr [-h] [--version] [--check] [--exclude PATTERN] [--verbose] [paths ...]
29
+
30
+ Format Markdown files for py-edu-fr project
31
+
32
+ positional arguments:
33
+ paths Files or directories to format (if omitted, reads from stdin)
34
+
35
+ options:
36
+ -h, --help show this help message and exit
37
+ --version show program's version number and exit
38
+ --check Check if files are formatted without modifying them (exit code 1 if changes needed)
39
+ --exclude PATTERN Glob pattern to exclude files/directories (can be specified multiple times)
40
+ --verbose, -v Print detailed information about processing
41
+ ```
@@ -0,0 +1,15 @@
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.6.dist-info/METADATA,sha256=AAaWpFdS1JDvW1cjbNRpMu8b9HX4cWdWOhdH9h37qUA,1506
7
+ mdformat_py_edu_fr-0.1.6.dist-info/WHEEL,sha256=RSz6kXtM6TF3IJK4O7lm4V0QzFJUHF9t5pxUi4i5D34,104
8
+ mdformat_py_edu_fr-0.1.6.dist-info/entry_points.txt,sha256=dNsCwKXSNYQUbo19idpAR7WOLX6XiklUmMmb_Tfj7gs,149
9
+ mdformat_py_edu_fr-0.1.6.dist-info/licenses/LICENSE.txt,sha256=86p_CLJNsO7121AMS-JOKevnqSH4FKqOAbRPaf2Bu0U,1524
10
+ mdformat_py_edu_fr/__init__.py,sha256=a87wI51gBRuFn1_IauF42noia0IE4t0kmOiJRIWVVl4,3312
11
+ mdformat_py_edu_fr/__main__.py,sha256=SI9uVJaNxyo8MVoAzb2pJWdXszT-zdWl6afekldFn2c,2509
12
+ mdformat_py_edu_fr/format_with_jupytext.py,sha256=-YoeTGofr3iL51CLXSD4phyMD5OWwsiu8EuBJ1roE50,2334
13
+ mdformat_py_edu_fr/jupytext.toml,sha256=ZQwweaKSvIGXWw-lEwJfnzTPSbEqiGQjNmPBxsQVxmQ,872
14
+ mdformat_py_edu_fr/util_ruff.py,sha256=FDatJwUPcB3YElJmLfNfXh3ERzJyTIkikGigXGVb8Fw,647
15
+ mdformat_py_edu_fr-0.1.6.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: pdm-backend (2.4.6.dev3+g3976183)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,8 @@
1
+ [console_scripts]
2
+ mdformat-py-edu-fr = mdformat_py_edu_fr.__main__:main
3
+
4
+ [gui_scripts]
5
+
6
+ [mdformat.parser_extension]
7
+ myst = mdformat_myst_pef.plugin
8
+
@@ -0,0 +1,27 @@
1
+ Copyright (c) 2025, py-edu-fr developers and Pierre Augier
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+
10
+ 2. Redistributions in binary form must reproduce the above copyright notice,
11
+ this list of conditions and the following disclaimer in the documentation
12
+ and/or other materials provided with the distribution.
13
+
14
+ 3. Neither the name of the copyright holder nor the names of its contributors
15
+ may be used to endorse or promote products derived from this software without
16
+ specific prior written permission.
17
+
18
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.