mystquarto 0.1.0__tar.gz

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,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Max Ghenis
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,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: mystquarto
3
+ Version: 0.1.0
4
+ Summary: Bidirectional MyST ↔ Quarto converter
5
+ Project-URL: Homepage, https://github.com/MaxGhenis/mystquarto
6
+ Project-URL: Repository, https://github.com/MaxGhenis/mystquarto
7
+ Author: Max Ghenis
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: converter,markdown,myst,quarto
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Text Processing :: Markup
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: click>=8.0
18
+ Requires-Dist: pyyaml>=6.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # mystquarto
22
+
23
+ Bidirectional MyST ↔ Quarto converter.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install mystquarto
29
+ # or
30
+ uvx mystquarto
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```bash
36
+ # Convert MyST → Quarto
37
+ myst2quarto docs/
38
+ mystquarto to-quarto docs/
39
+
40
+ # Convert Quarto → MyST
41
+ quarto2myst docs/
42
+ mystquarto to-myst docs/
43
+ ```
44
+
45
+ ### Options
46
+
47
+ - `--output DIR` / `-o DIR`: Output directory (default: converts in-place)
48
+ - `--in-place`: Modify files in-place
49
+ - `--config-only`: Only convert config files (myst.yml ↔ _quarto.yml)
50
+ - `--no-config`: Skip config file conversion
51
+ - `--dry-run`: Show what would be changed without writing
52
+ - `--strict`: Treat warnings as errors
@@ -0,0 +1,32 @@
1
+ # mystquarto
2
+
3
+ Bidirectional MyST ↔ Quarto converter.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install mystquarto
9
+ # or
10
+ uvx mystquarto
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ # Convert MyST → Quarto
17
+ myst2quarto docs/
18
+ mystquarto to-quarto docs/
19
+
20
+ # Convert Quarto → MyST
21
+ quarto2myst docs/
22
+ mystquarto to-myst docs/
23
+ ```
24
+
25
+ ### Options
26
+
27
+ - `--output DIR` / `-o DIR`: Output directory (default: converts in-place)
28
+ - `--in-place`: Modify files in-place
29
+ - `--config-only`: Only convert config files (myst.yml ↔ _quarto.yml)
30
+ - `--no-config`: Skip config file conversion
31
+ - `--dry-run`: Show what would be changed without writing
32
+ - `--strict`: Treat warnings as errors
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mystquarto"
7
+ version = "0.1.0"
8
+ description = "Bidirectional MyST ↔ Quarto converter"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Max Ghenis" }]
13
+ keywords = ["myst", "quarto", "markdown", "converter"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Text Processing :: Markup",
20
+ ]
21
+ dependencies = [
22
+ "click>=8.0",
23
+ "pyyaml>=6.0",
24
+ ]
25
+
26
+ [project.scripts]
27
+ myst2quarto = "mystquarto.cli:myst2quarto"
28
+ quarto2myst = "mystquarto.cli:quarto2myst"
29
+ mystquarto = "mystquarto.cli:main"
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/MaxGhenis/mystquarto"
33
+ Repository = "https://github.com/MaxGhenis/mystquarto"
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+
38
+ [tool.ruff]
39
+ line-length = 88
@@ -0,0 +1,3 @@
1
+ """Bidirectional MyST ↔ Quarto converter."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,175 @@
1
+ """Click CLI for mystquarto: bidirectional MyST <-> Quarto converter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+
9
+ from mystquarto.convert import Direction, convert_directory
10
+ from mystquarto.warnings import WarningCollector
11
+
12
+
13
+ def _run_conversion(
14
+ path: str,
15
+ output: str | None,
16
+ in_place: bool,
17
+ config_only: bool,
18
+ no_config: bool,
19
+ dry_run: bool,
20
+ strict: bool,
21
+ direction: Direction,
22
+ ) -> None:
23
+ """Shared implementation for both conversion directions.
24
+
25
+ Args:
26
+ path: Input file or directory path.
27
+ output: Output directory path (or None).
28
+ in_place: Whether to modify files in-place.
29
+ config_only: Only convert config files.
30
+ no_config: Skip config file conversion.
31
+ dry_run: Show what would change without writing.
32
+ strict: Treat warnings as errors.
33
+ direction: Conversion direction.
34
+ """
35
+ collector = WarningCollector(strict=strict)
36
+
37
+ results = convert_directory(
38
+ input_dir=path,
39
+ output_dir=output,
40
+ direction=direction,
41
+ in_place=in_place,
42
+ config_only=config_only,
43
+ no_config=no_config,
44
+ dry_run=dry_run,
45
+ )
46
+
47
+ # Collect warnings and errors from results
48
+ for result in results:
49
+ for warning in result.warnings:
50
+ collector.warn(warning)
51
+ for error in result.errors:
52
+ collector.error(error)
53
+
54
+ # Report
55
+ converted_count = sum(
56
+ 1 for r in results if not r.skipped and not r.errors
57
+ )
58
+ label = "Would convert" if dry_run else "Converted"
59
+
60
+ if dry_run:
61
+ for result in results:
62
+ if not result.skipped and result.output_path:
63
+ click.echo(f" {result.input_path} -> {result.output_path}")
64
+
65
+ click.echo(f"{label} {converted_count} file(s).")
66
+
67
+ if collector.warnings or collector.errors:
68
+ click.echo(collector.report())
69
+
70
+ if collector.has_errors():
71
+ sys.exit(1)
72
+
73
+
74
+ @click.command()
75
+ @click.argument("path", type=click.Path(exists=True))
76
+ @click.option("--output", "-o", type=click.Path(), help="Output directory")
77
+ @click.option("--in-place", is_flag=True, help="Modify files in-place")
78
+ @click.option("--config-only", is_flag=True, help="Only convert config files")
79
+ @click.option("--no-config", is_flag=True, help="Skip config file conversion")
80
+ @click.option(
81
+ "--dry-run", is_flag=True, help="Show what would change without writing"
82
+ )
83
+ @click.option("--strict", is_flag=True, help="Treat warnings as errors")
84
+ def myst2quarto(path, output, in_place, config_only, no_config, dry_run, strict):
85
+ """Convert MyST markdown files to Quarto format."""
86
+ _run_conversion(
87
+ path=path,
88
+ output=output,
89
+ in_place=in_place,
90
+ config_only=config_only,
91
+ no_config=no_config,
92
+ dry_run=dry_run,
93
+ strict=strict,
94
+ direction=Direction.MYST_TO_QUARTO,
95
+ )
96
+
97
+
98
+ @click.command()
99
+ @click.argument("path", type=click.Path(exists=True))
100
+ @click.option("--output", "-o", type=click.Path(), help="Output directory")
101
+ @click.option("--in-place", is_flag=True, help="Modify files in-place")
102
+ @click.option("--config-only", is_flag=True, help="Only convert config files")
103
+ @click.option("--no-config", is_flag=True, help="Skip config file conversion")
104
+ @click.option(
105
+ "--dry-run", is_flag=True, help="Show what would change without writing"
106
+ )
107
+ @click.option("--strict", is_flag=True, help="Treat warnings as errors")
108
+ def quarto2myst(path, output, in_place, config_only, no_config, dry_run, strict):
109
+ """Convert Quarto markdown files to MyST format."""
110
+ _run_conversion(
111
+ path=path,
112
+ output=output,
113
+ in_place=in_place,
114
+ config_only=config_only,
115
+ no_config=no_config,
116
+ dry_run=dry_run,
117
+ strict=strict,
118
+ direction=Direction.QUARTO_TO_MYST,
119
+ )
120
+
121
+
122
+ @click.group(invoke_without_command=True)
123
+ @click.pass_context
124
+ def main(ctx):
125
+ """Bidirectional MyST <-> Quarto converter."""
126
+ if ctx.invoked_subcommand is None:
127
+ click.echo(ctx.get_help())
128
+
129
+
130
+ @main.command("to-quarto")
131
+ @click.argument("path", type=click.Path(exists=True))
132
+ @click.option("--output", "-o", type=click.Path(), help="Output directory")
133
+ @click.option("--in-place", is_flag=True, help="Modify files in-place")
134
+ @click.option("--config-only", is_flag=True, help="Only convert config files")
135
+ @click.option("--no-config", is_flag=True, help="Skip config file conversion")
136
+ @click.option(
137
+ "--dry-run", is_flag=True, help="Show what would change without writing"
138
+ )
139
+ @click.option("--strict", is_flag=True, help="Treat warnings as errors")
140
+ def to_quarto(path, output, in_place, config_only, no_config, dry_run, strict):
141
+ """Convert MyST markdown files to Quarto format."""
142
+ _run_conversion(
143
+ path=path,
144
+ output=output,
145
+ in_place=in_place,
146
+ config_only=config_only,
147
+ no_config=no_config,
148
+ dry_run=dry_run,
149
+ strict=strict,
150
+ direction=Direction.MYST_TO_QUARTO,
151
+ )
152
+
153
+
154
+ @main.command("to-myst")
155
+ @click.argument("path", type=click.Path(exists=True))
156
+ @click.option("--output", "-o", type=click.Path(), help="Output directory")
157
+ @click.option("--in-place", is_flag=True, help="Modify files in-place")
158
+ @click.option("--config-only", is_flag=True, help="Only convert config files")
159
+ @click.option("--no-config", is_flag=True, help="Skip config file conversion")
160
+ @click.option(
161
+ "--dry-run", is_flag=True, help="Show what would change without writing"
162
+ )
163
+ @click.option("--strict", is_flag=True, help="Treat warnings as errors")
164
+ def to_myst(path, output, in_place, config_only, no_config, dry_run, strict):
165
+ """Convert Quarto markdown files to MyST format."""
166
+ _run_conversion(
167
+ path=path,
168
+ output=output,
169
+ in_place=in_place,
170
+ config_only=config_only,
171
+ no_config=no_config,
172
+ dry_run=dry_run,
173
+ strict=strict,
174
+ direction=Direction.QUARTO_TO_MYST,
175
+ )
@@ -0,0 +1,271 @@
1
+ """Config conversion: myst.yml <-> _quarto.yml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import yaml
8
+
9
+
10
+ def _is_book_project(myst_config: dict) -> bool:
11
+ """Detect if a MyST config represents a book-type project.
12
+
13
+ Book type is indicated by either:
14
+ - site.template == "book-theme"
15
+ - presence of project.toc
16
+ """
17
+ site = myst_config.get("site", {})
18
+ if site.get("template") == "book-theme":
19
+ return True
20
+ project = myst_config.get("project", {})
21
+ if "toc" in project:
22
+ return True
23
+ return False
24
+
25
+
26
+ def _toc_to_chapters(toc: list[dict]) -> list[str]:
27
+ """Convert MyST toc entries to Quarto chapter file list.
28
+
29
+ Each entry is a dict with a 'file' key (with or without extension).
30
+ Returns list of filenames with .qmd extension.
31
+ """
32
+ chapters = []
33
+ for entry in toc:
34
+ if isinstance(entry, dict) and "file" in entry:
35
+ name = entry["file"]
36
+ elif isinstance(entry, str):
37
+ name = entry
38
+ else:
39
+ continue
40
+ # Strip existing .md extension before adding .qmd
41
+ if name.endswith(".md"):
42
+ name = name[:-3]
43
+ chapters.append(f"{name}.qmd")
44
+ return chapters
45
+
46
+
47
+ def _chapters_to_toc(chapters: list[str]) -> list[dict]:
48
+ """Convert Quarto chapter file list to MyST toc entries.
49
+
50
+ Each chapter is a filename (possibly with .qmd extension).
51
+ Returns list of dicts with 'file' key (no extension).
52
+ """
53
+ toc = []
54
+ for chapter in chapters:
55
+ filename = chapter
56
+ # Strip .qmd or .md extension
57
+ for ext in (".qmd", ".md"):
58
+ if filename.endswith(ext):
59
+ filename = filename[: -len(ext)]
60
+ break
61
+ toc.append({"file": filename})
62
+ return toc
63
+
64
+
65
+ def _convert_authors_myst_to_quarto(authors: list[dict]) -> list[dict]:
66
+ """Convert MyST author entries to Quarto format.
67
+
68
+ Both use similar structures with name and affiliations.
69
+ """
70
+ result = []
71
+ for author in authors:
72
+ entry = {}
73
+ if "name" in author:
74
+ entry["name"] = author["name"]
75
+ if "affiliations" in author:
76
+ entry["affiliations"] = author["affiliations"]
77
+ # Pass through any other fields
78
+ for key, value in author.items():
79
+ if key not in ("name", "affiliations"):
80
+ entry[key] = value
81
+ result.append(entry)
82
+ return result
83
+
84
+
85
+ def _convert_exports_to_format(exports: list[dict]) -> dict:
86
+ """Convert MyST exports list to Quarto format block.
87
+
88
+ Each export has a 'format' key (pdf, docx, etc.) and other options.
89
+ """
90
+ format_block = {}
91
+ for export in exports:
92
+ fmt = export.get("format")
93
+ if not fmt:
94
+ continue
95
+ # Copy all options except 'format' itself
96
+ options = {k: v for k, v in export.items() if k != "format"}
97
+ format_block[fmt] = options if options else {}
98
+ return format_block
99
+
100
+
101
+ def _convert_format_to_exports(format_block: dict) -> list[dict]:
102
+ """Convert Quarto format block to MyST exports list."""
103
+ exports = []
104
+ for fmt, options in format_block.items():
105
+ export = {"format": fmt}
106
+ if isinstance(options, dict):
107
+ export.update(options)
108
+ exports.append(export)
109
+ return exports
110
+
111
+
112
+ def myst_to_quarto_config(myst_config: dict) -> dict:
113
+ """Convert a parsed myst.yml dict to a _quarto.yml dict.
114
+
115
+ Handles both book-type and article/manuscript projects.
116
+ """
117
+ if not myst_config:
118
+ return {}
119
+
120
+ project = myst_config.get("project", {})
121
+ if not project:
122
+ return {}
123
+
124
+ result = {}
125
+ is_book = _is_book_project(myst_config)
126
+
127
+ if is_book:
128
+ # Book-type project
129
+ result["project"] = {"type": "book"}
130
+ book = {}
131
+
132
+ if "title" in project:
133
+ book["title"] = project["title"]
134
+ if "authors" in project:
135
+ book["author"] = _convert_authors_myst_to_quarto(project["authors"])
136
+ if "toc" in project:
137
+ book["chapters"] = _toc_to_chapters(project["toc"])
138
+
139
+ result["book"] = book
140
+ else:
141
+ # Article/manuscript project
142
+ if "title" in project:
143
+ result["title"] = project["title"]
144
+ if "authors" in project:
145
+ result["author"] = _convert_authors_myst_to_quarto(project["authors"])
146
+
147
+ # Fields that apply to both types
148
+ if "bibliography" in project:
149
+ result["bibliography"] = project["bibliography"]
150
+
151
+ if "exports" in project:
152
+ result["format"] = _convert_exports_to_format(project["exports"])
153
+
154
+ if "github" in project:
155
+ result["repo-url"] = project["github"]
156
+
157
+ if "license" in project:
158
+ result["license"] = project["license"]
159
+
160
+ if "keywords" in project:
161
+ result["keywords"] = project["keywords"]
162
+
163
+ if "date" in project:
164
+ result["date"] = project["date"]
165
+
166
+ if "subject" in project:
167
+ result["description"] = project["subject"]
168
+
169
+ return result
170
+
171
+
172
+ def quarto_to_myst_config(quarto_config: dict) -> dict:
173
+ """Convert a parsed _quarto.yml dict to a myst.yml dict.
174
+
175
+ Handles both book-type and article/manuscript projects.
176
+ """
177
+ if not quarto_config:
178
+ return {}
179
+
180
+ result = {}
181
+ project = {}
182
+ is_book = (
183
+ quarto_config.get("project", {}).get("type") == "book"
184
+ or "book" in quarto_config
185
+ )
186
+
187
+ if is_book:
188
+ book = quarto_config.get("book", {})
189
+ if "title" in book:
190
+ project["title"] = book["title"]
191
+ if "author" in book:
192
+ project["authors"] = book["author"]
193
+ if "chapters" in book:
194
+ project["toc"] = _chapters_to_toc(book["chapters"])
195
+ result["site"] = {"template": "book-theme"}
196
+ else:
197
+ if "title" in quarto_config:
198
+ project["title"] = quarto_config["title"]
199
+ if "author" in quarto_config:
200
+ project["authors"] = quarto_config["author"]
201
+
202
+ # Fields that apply to both types
203
+ if "bibliography" in quarto_config:
204
+ project["bibliography"] = quarto_config["bibliography"]
205
+
206
+ if "format" in quarto_config:
207
+ project["exports"] = _convert_format_to_exports(quarto_config["format"])
208
+
209
+ if "repo-url" in quarto_config:
210
+ project["github"] = quarto_config["repo-url"]
211
+
212
+ if "license" in quarto_config:
213
+ project["license"] = quarto_config["license"]
214
+
215
+ if "keywords" in quarto_config:
216
+ project["keywords"] = quarto_config["keywords"]
217
+
218
+ if "date" in quarto_config:
219
+ project["date"] = quarto_config["date"]
220
+
221
+ if "description" in quarto_config:
222
+ project["subject"] = quarto_config["description"]
223
+
224
+ if project:
225
+ result["project"] = project
226
+
227
+ return result
228
+
229
+
230
+ def convert_myst_config(myst_yml_path: str, output_dir: str) -> str:
231
+ """Read myst.yml, convert to _quarto.yml, write to output_dir.
232
+
233
+ Args:
234
+ myst_yml_path: Path to the input myst.yml file.
235
+ output_dir: Directory to write _quarto.yml to.
236
+
237
+ Returns:
238
+ Path to the output _quarto.yml file.
239
+ """
240
+ with open(myst_yml_path) as f:
241
+ myst_config = yaml.safe_load(f) or {}
242
+
243
+ quarto_config = myst_to_quarto_config(myst_config)
244
+
245
+ output_path = os.path.join(output_dir, "_quarto.yml")
246
+ with open(output_path, "w") as f:
247
+ yaml.dump(quarto_config, f, default_flow_style=False, sort_keys=False)
248
+
249
+ return output_path
250
+
251
+
252
+ def convert_quarto_config(quarto_yml_path: str, output_dir: str) -> str:
253
+ """Read _quarto.yml, convert to myst.yml, write to output_dir.
254
+
255
+ Args:
256
+ quarto_yml_path: Path to the input _quarto.yml file.
257
+ output_dir: Directory to write myst.yml to.
258
+
259
+ Returns:
260
+ Path to the output myst.yml file.
261
+ """
262
+ with open(quarto_yml_path) as f:
263
+ quarto_config = yaml.safe_load(f) or {}
264
+
265
+ myst_config = quarto_to_myst_config(quarto_config)
266
+
267
+ output_path = os.path.join(output_dir, "myst.yml")
268
+ with open(output_path, "w") as f:
269
+ yaml.dump(myst_config, f, default_flow_style=False, sort_keys=False)
270
+
271
+ return output_path