sphinx-data2table 0.1.1__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.
- sphinx_data2table-0.1.1/LICENSE +21 -0
- sphinx_data2table-0.1.1/PKG-INFO +12 -0
- sphinx_data2table-0.1.1/README.md +0 -0
- sphinx_data2table-0.1.1/pyproject.toml +73 -0
- sphinx_data2table-0.1.1/setup.cfg +4 -0
- sphinx_data2table-0.1.1/src/sphinx_data2table/__init__.py +35 -0
- sphinx_data2table-0.1.1/src/sphinx_data2table/directive.py +333 -0
- sphinx_data2table-0.1.1/src/sphinx_data2table.egg-info/PKG-INFO +12 -0
- sphinx_data2table-0.1.1/src/sphinx_data2table.egg-info/SOURCES.txt +11 -0
- sphinx_data2table-0.1.1/src/sphinx_data2table.egg-info/dependency_links.txt +1 -0
- sphinx_data2table-0.1.1/src/sphinx_data2table.egg-info/requires.txt +2 -0
- sphinx_data2table-0.1.1/src/sphinx_data2table.egg-info/top_level.txt +1 -0
- sphinx_data2table-0.1.1/tests/test_directive.py +231 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jagarikokamukamu
|
|
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,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sphinx-data2table
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Sphinx data-table extension for TOML, YAML, and JSON with Markdown cell parsing
|
|
5
|
+
Author: jagarikokamukamu
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
11
|
+
Requires-Dist: sphinx>=8.0
|
|
12
|
+
Dynamic: license-file
|
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sphinx-data2table"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Sphinx data-table extension for TOML, YAML, and JSON with Markdown cell parsing"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{ name = "jagarikokamukamu" }]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = ["pyyaml>=6.0.3", "sphinx>=8.0"]
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["setuptools>=61.0"]
|
|
13
|
+
build-backend = "setuptools.build_meta"
|
|
14
|
+
|
|
15
|
+
[tool.setuptools.packages.find]
|
|
16
|
+
where = ["src"]
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = [
|
|
20
|
+
"bandit>=1.9.4",
|
|
21
|
+
"myst-parser>=5.1.0",
|
|
22
|
+
"pip-audit>=2.10.1",
|
|
23
|
+
"pyright>=1.1.411",
|
|
24
|
+
"pytest-cov>=7.1.0",
|
|
25
|
+
"pytest>=9.1.1",
|
|
26
|
+
"ruff>=0.16.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
pythonpath = ["src"]
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
|
|
33
|
+
[tool.bandit]
|
|
34
|
+
exclude_dirs = ["tests", ".venv"]
|
|
35
|
+
skips = ["B110"]
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 88
|
|
39
|
+
target-version = "py313"
|
|
40
|
+
|
|
41
|
+
[tool.ruff.lint]
|
|
42
|
+
select = [
|
|
43
|
+
"E", # pycodestyle errors
|
|
44
|
+
"W", # pycodestyle warnings
|
|
45
|
+
"F", # pyflakes
|
|
46
|
+
"I", # isort
|
|
47
|
+
"B", # flake8-bugbear
|
|
48
|
+
"C4", # flake8-comprehensions
|
|
49
|
+
"UP", # pyupgrade
|
|
50
|
+
"D100", # Missing docstring in public module (files)
|
|
51
|
+
"D101", # Missing docstring in public class
|
|
52
|
+
"D102", # Missing docstring in public method
|
|
53
|
+
"D103", # Missing docstring in public function
|
|
54
|
+
"D201",
|
|
55
|
+
"D202", # missing-blank-line-before-summary
|
|
56
|
+
"D205", # missing-blank-line-after-summary
|
|
57
|
+
"D417", # undocumented-param
|
|
58
|
+
]
|
|
59
|
+
ignore = []
|
|
60
|
+
|
|
61
|
+
[tool.ruff.lint.per-file-ignores]
|
|
62
|
+
"tests/*" = ["D"]
|
|
63
|
+
|
|
64
|
+
[tool.ruff.format]
|
|
65
|
+
quote-style = "double"
|
|
66
|
+
indent-style = "space"
|
|
67
|
+
|
|
68
|
+
[tool.ruff.lint.pydocstyle]
|
|
69
|
+
convention = "google"
|
|
70
|
+
|
|
71
|
+
[tool.pyright]
|
|
72
|
+
pythonVersion = "3.13"
|
|
73
|
+
typeCheckingMode = "basic"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Sphinx Data2Table Extension.
|
|
2
|
+
|
|
3
|
+
Renders TOML, YAML, or JSON data as HTML/LaTeX tables with Markdown cell parsing.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from sphinx_data2table.directive import DataTableDirective
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from sphinx.application import Sphinx
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def setup(app: Sphinx) -> dict[str, Any]:
|
|
19
|
+
"""Registers the data-table and datatable directives with the Sphinx application.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
app: The active Sphinx application instance.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
A dictionary containing metadata about the extension, including
|
|
26
|
+
version and parallel read/write safety flags.
|
|
27
|
+
"""
|
|
28
|
+
app.add_directive("data-table", DataTableDirective)
|
|
29
|
+
app.add_directive("datatable", DataTableDirective)
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
"version": __version__,
|
|
33
|
+
"parallel_read_safe": True,
|
|
34
|
+
"parallel_write_safe": True,
|
|
35
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Directive implementation for rendering TOML, YAML, and JSON tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import textwrap
|
|
9
|
+
import tomllib
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from docutils import nodes
|
|
14
|
+
from docutils.parsers.rst import Directive, directives
|
|
15
|
+
from docutils.statemachine import StringList
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DataTableDirective(Directive):
|
|
19
|
+
"""Sphinx/Docutils directive to render TOML, YAML, or JSON data as tables.
|
|
20
|
+
|
|
21
|
+
This directive parses raw inline TOML/YAML/JSON text or external data files
|
|
22
|
+
into structured data and constructs docutils table nodes. Each cell's text is
|
|
23
|
+
parsed via nested_parse to support Markdown inline and block elements.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
has_content = True
|
|
27
|
+
required_arguments = 0
|
|
28
|
+
optional_arguments = 0
|
|
29
|
+
final_argument_whitespace = False
|
|
30
|
+
option_spec = {
|
|
31
|
+
"file": directives.path,
|
|
32
|
+
"format": directives.unchanged,
|
|
33
|
+
"headers": directives.unchanged,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
def run(self) -> list[nodes.Node]:
|
|
37
|
+
"""Main execution method invoked by docutils when parsing directive.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
A list containing constructed docutils table node or error nodes.
|
|
41
|
+
"""
|
|
42
|
+
env = (
|
|
43
|
+
self.state.document.settings.env
|
|
44
|
+
if hasattr(self.state.document.settings, "env")
|
|
45
|
+
else None
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# 1. Obtain raw data content
|
|
49
|
+
file_path = self.options.get("file")
|
|
50
|
+
raw_text = ""
|
|
51
|
+
|
|
52
|
+
if file_path:
|
|
53
|
+
if env:
|
|
54
|
+
# Resolve relative path to sphinx doc source directory
|
|
55
|
+
rel_path, abs_path = env.relfn2path(file_path)
|
|
56
|
+
env.note_dependency(rel_path)
|
|
57
|
+
else:
|
|
58
|
+
abs_path = os.path.abspath(file_path)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
with open(abs_path, encoding="utf-8") as f:
|
|
62
|
+
raw_text = f.read()
|
|
63
|
+
except OSError as err:
|
|
64
|
+
return [
|
|
65
|
+
self.state_machine.reporter.error(
|
|
66
|
+
f"data-table: Could not read file '{file_path}': {err}",
|
|
67
|
+
line=self.lineno,
|
|
68
|
+
)
|
|
69
|
+
]
|
|
70
|
+
elif self.content:
|
|
71
|
+
raw_text = textwrap.dedent("\n".join(self.content)).strip()
|
|
72
|
+
else:
|
|
73
|
+
return [
|
|
74
|
+
self.state_machine.reporter.error(
|
|
75
|
+
"data-table: Neither content nor ':file:' option provided.",
|
|
76
|
+
line=self.lineno,
|
|
77
|
+
)
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
# 2. Determine format (yaml / toml / json)
|
|
81
|
+
fmt = self.options.get("format", "auto").lower()
|
|
82
|
+
|
|
83
|
+
if fmt == "auto":
|
|
84
|
+
if file_path:
|
|
85
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
86
|
+
if ext in (".toml",):
|
|
87
|
+
fmt = "toml"
|
|
88
|
+
elif ext in (".yaml", ".yml"):
|
|
89
|
+
fmt = "yaml"
|
|
90
|
+
elif ext in (".json",):
|
|
91
|
+
fmt = "json"
|
|
92
|
+
if fmt == "auto":
|
|
93
|
+
fmt = self._guess_format(raw_text)
|
|
94
|
+
|
|
95
|
+
# 3. Parse data
|
|
96
|
+
data, parse_err = self._parse_data(raw_text, fmt)
|
|
97
|
+
if parse_err:
|
|
98
|
+
return [
|
|
99
|
+
self.state_machine.reporter.error(
|
|
100
|
+
f"data-table: Failed to parse {fmt.upper()} data: {parse_err}",
|
|
101
|
+
line=self.lineno,
|
|
102
|
+
)
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
rows_data = self._normalize_rows(data)
|
|
106
|
+
if not rows_data:
|
|
107
|
+
return [
|
|
108
|
+
self.state_machine.reporter.warning(
|
|
109
|
+
"data-table: Data is empty or invalid format.",
|
|
110
|
+
line=self.lineno,
|
|
111
|
+
)
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
# 4. Determine Headers
|
|
115
|
+
headers_opt = self.options.get("headers")
|
|
116
|
+
if headers_opt:
|
|
117
|
+
headers = [h.strip() for h in headers_opt.split(",") if h.strip()]
|
|
118
|
+
else:
|
|
119
|
+
# Collect all unique keys across rows while preserving order
|
|
120
|
+
headers = []
|
|
121
|
+
for row in rows_data:
|
|
122
|
+
if isinstance(row, dict):
|
|
123
|
+
for k in row.keys():
|
|
124
|
+
if k not in headers:
|
|
125
|
+
headers.append(k)
|
|
126
|
+
|
|
127
|
+
if not headers:
|
|
128
|
+
return [
|
|
129
|
+
self.state_machine.reporter.error(
|
|
130
|
+
"data-table: Could not determine headers from data.",
|
|
131
|
+
line=self.lineno,
|
|
132
|
+
)
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
# 5. Build Docutils Table Node
|
|
136
|
+
table_node = self._build_table_node(headers, rows_data)
|
|
137
|
+
return [table_node]
|
|
138
|
+
|
|
139
|
+
def _guess_format(self, text: str) -> str:
|
|
140
|
+
"""Guesses whether raw text content is JSON, TOML, or YAML.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
text: The raw string content of the data.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
The string 'json', 'toml', or 'yaml'. Defaults to 'yaml' if ambiguous.
|
|
147
|
+
"""
|
|
148
|
+
# Try JSON first
|
|
149
|
+
with contextlib.suppress(Exception):
|
|
150
|
+
parsed_json = json.loads(text)
|
|
151
|
+
if isinstance(parsed_json, (list, dict)):
|
|
152
|
+
return "json"
|
|
153
|
+
|
|
154
|
+
# Try TOML
|
|
155
|
+
with contextlib.suppress(Exception):
|
|
156
|
+
parsed_toml = tomllib.loads(text)
|
|
157
|
+
if isinstance(parsed_toml, dict) and parsed_toml:
|
|
158
|
+
return "toml"
|
|
159
|
+
|
|
160
|
+
# Try YAML
|
|
161
|
+
with contextlib.suppress(Exception):
|
|
162
|
+
parsed_yaml = yaml.safe_load(text)
|
|
163
|
+
if isinstance(parsed_yaml, (list, dict)):
|
|
164
|
+
return "yaml"
|
|
165
|
+
|
|
166
|
+
return "yaml"
|
|
167
|
+
|
|
168
|
+
def _parse_data(self, text: str, fmt: str) -> tuple[Any, str | None]:
|
|
169
|
+
"""Parses raw text data using the specified format parser.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
text: Raw data text string.
|
|
173
|
+
fmt: Format string ('json', 'toml', or 'yaml').
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
A tuple containing (parsed_object, error_message_or_None).
|
|
177
|
+
"""
|
|
178
|
+
if fmt == "json":
|
|
179
|
+
try:
|
|
180
|
+
return json.loads(text), None
|
|
181
|
+
except Exception as e:
|
|
182
|
+
return None, str(e)
|
|
183
|
+
elif fmt == "toml":
|
|
184
|
+
try:
|
|
185
|
+
return tomllib.loads(text), None
|
|
186
|
+
except Exception as e:
|
|
187
|
+
return None, str(e)
|
|
188
|
+
elif fmt == "yaml":
|
|
189
|
+
try:
|
|
190
|
+
return yaml.safe_load(text), None
|
|
191
|
+
except Exception as e:
|
|
192
|
+
return None, str(e)
|
|
193
|
+
else:
|
|
194
|
+
return None, f"Unsupported format '{fmt}'. Use 'json', 'yaml', or 'toml'."
|
|
195
|
+
|
|
196
|
+
def _normalize_rows(self, data: Any) -> list[dict[str, Any]]:
|
|
197
|
+
"""Normalizes parsed data into a list of row dictionaries.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
data: Parsed data resulting from JSON, TOML, or YAML parser.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
A list of dictionary objects representing table rows.
|
|
204
|
+
"""
|
|
205
|
+
if isinstance(data, list):
|
|
206
|
+
return [item for item in data if isinstance(item, dict)]
|
|
207
|
+
elif isinstance(data, dict):
|
|
208
|
+
# If root is a dict containing a list of dicts (e.g. {"items": [...]})
|
|
209
|
+
for val in data.values():
|
|
210
|
+
if isinstance(val, list) and all(isinstance(x, dict) for x in val):
|
|
211
|
+
return val
|
|
212
|
+
# Or a single row dict
|
|
213
|
+
return [data]
|
|
214
|
+
return []
|
|
215
|
+
|
|
216
|
+
def _build_table_node(
|
|
217
|
+
self, headers: list[str], rows_data: list[dict[str, Any]]
|
|
218
|
+
) -> nodes.table:
|
|
219
|
+
"""Constructs docutils table nodes and parses Markdown for each cell.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
headers: A list of column header names.
|
|
223
|
+
rows_data: A list of row dictionary objects containing cell contents.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
A docutils.nodes.table instance containing header and body rows.
|
|
227
|
+
"""
|
|
228
|
+
table = nodes.table()
|
|
229
|
+
table["classes"] += ["datatable"]
|
|
230
|
+
tgroup = nodes.tgroup(cols=len(headers))
|
|
231
|
+
table += tgroup
|
|
232
|
+
|
|
233
|
+
for _ in headers:
|
|
234
|
+
tgroup += nodes.colspec(colwidth=1)
|
|
235
|
+
|
|
236
|
+
# Header Row
|
|
237
|
+
thead = nodes.thead()
|
|
238
|
+
tgroup += thead
|
|
239
|
+
header_row = nodes.row()
|
|
240
|
+
thead += header_row
|
|
241
|
+
|
|
242
|
+
for header_text in headers:
|
|
243
|
+
entry = nodes.entry()
|
|
244
|
+
self._parse_cell_markdown(str(header_text), entry)
|
|
245
|
+
header_row += entry
|
|
246
|
+
|
|
247
|
+
# Body Rows
|
|
248
|
+
tbody = nodes.tbody()
|
|
249
|
+
tgroup += tbody
|
|
250
|
+
|
|
251
|
+
for row_dict in rows_data:
|
|
252
|
+
row_node = nodes.row()
|
|
253
|
+
tbody += row_node
|
|
254
|
+
for h in headers:
|
|
255
|
+
entry = nodes.entry()
|
|
256
|
+
cell_value = row_dict.get(h, "")
|
|
257
|
+
if cell_value is None:
|
|
258
|
+
cell_value = ""
|
|
259
|
+
self._parse_cell_markdown(str(cell_value), entry)
|
|
260
|
+
row_node += entry
|
|
261
|
+
|
|
262
|
+
return table
|
|
263
|
+
|
|
264
|
+
def _parse_cell_markdown(self, content_str: str, entry_node: nodes.entry) -> None:
|
|
265
|
+
"""Parses cell string as Markdown/reST AST into docutils nodes in entry_node.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
content_str: The raw text content of the table cell.
|
|
269
|
+
entry_node: Target docutils.nodes.entry node to append child nodes into.
|
|
270
|
+
"""
|
|
271
|
+
dedented_str = textwrap.dedent(content_str).strip()
|
|
272
|
+
if not dedented_str:
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
lines = dedented_str.splitlines()
|
|
276
|
+
string_list = StringList(lines, source="datatable_cell")
|
|
277
|
+
|
|
278
|
+
# Create temporary container node to hold nested parsed nodes
|
|
279
|
+
container = nodes.Element()
|
|
280
|
+
self.state.nested_parse(string_list, 0, container)
|
|
281
|
+
|
|
282
|
+
# Post-process container nodes to replace in-cell newlines with latex-safe
|
|
283
|
+
self._replace_cell_newlines(container)
|
|
284
|
+
|
|
285
|
+
# Transfer children from container to entry_node
|
|
286
|
+
for child in container.children:
|
|
287
|
+
entry_node += child
|
|
288
|
+
|
|
289
|
+
def _replace_cell_newlines(self, node: nodes.Node) -> None:
|
|
290
|
+
"""Recursively replaces in-cell line breaks with raw break nodes.
|
|
291
|
+
|
|
292
|
+
WORKAROUND:
|
|
293
|
+
Sphinx's default LaTeXTranslator turns in-cell line break nodes into
|
|
294
|
+
'\\\\', which LaTeX interprets as a table row separator.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
node: The docutils AST node to process recursively.
|
|
298
|
+
"""
|
|
299
|
+
new_children: list[nodes.Node] = []
|
|
300
|
+
modified = False
|
|
301
|
+
|
|
302
|
+
for child in list(node.children):
|
|
303
|
+
if (
|
|
304
|
+
isinstance(child, nodes.raw)
|
|
305
|
+
and child.get("format") == "latex"
|
|
306
|
+
and child.astext().strip() in ("\\\\", r"\\")
|
|
307
|
+
):
|
|
308
|
+
modified = True
|
|
309
|
+
latex_break = nodes.raw("", r"\newline ", format="latex")
|
|
310
|
+
latex_break.parent = node
|
|
311
|
+
new_children.append(latex_break)
|
|
312
|
+
elif isinstance(child, nodes.Text) and "\n" in child:
|
|
313
|
+
modified = True
|
|
314
|
+
parts = str(child).split("\n")
|
|
315
|
+
for i, part in enumerate(parts):
|
|
316
|
+
clean_part = part.rstrip()
|
|
317
|
+
if clean_part:
|
|
318
|
+
t = nodes.Text(clean_part)
|
|
319
|
+
t.parent = node
|
|
320
|
+
new_children.append(t)
|
|
321
|
+
if i < len(parts) - 1:
|
|
322
|
+
latex_break = nodes.raw("", r"\newline ", format="latex")
|
|
323
|
+
html_break = nodes.raw("", "<br/>", format="html")
|
|
324
|
+
latex_break.parent = node
|
|
325
|
+
html_break.parent = node
|
|
326
|
+
new_children.extend([latex_break, html_break])
|
|
327
|
+
else:
|
|
328
|
+
self._replace_cell_newlines(child)
|
|
329
|
+
child.parent = node
|
|
330
|
+
new_children.append(child)
|
|
331
|
+
|
|
332
|
+
if modified:
|
|
333
|
+
node.children = new_children
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sphinx-data2table
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Sphinx data-table extension for TOML, YAML, and JSON with Markdown cell parsing
|
|
5
|
+
Author: jagarikokamukamu
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
11
|
+
Requires-Dist: sphinx>=8.0
|
|
12
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/sphinx_data2table/__init__.py
|
|
5
|
+
src/sphinx_data2table/directive.py
|
|
6
|
+
src/sphinx_data2table.egg-info/PKG-INFO
|
|
7
|
+
src/sphinx_data2table.egg-info/SOURCES.txt
|
|
8
|
+
src/sphinx_data2table.egg-info/dependency_links.txt
|
|
9
|
+
src/sphinx_data2table.egg-info/requires.txt
|
|
10
|
+
src/sphinx_data2table.egg-info/top_level.txt
|
|
11
|
+
tests/test_directive.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sphinx_data2table
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Unit test suite for sphinx_data2table package and DataTableDirective."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock
|
|
6
|
+
|
|
7
|
+
from docutils import nodes
|
|
8
|
+
from docutils.frontend import get_default_settings
|
|
9
|
+
from docutils.parsers.rst import Parser, directives
|
|
10
|
+
from docutils.utils import new_document
|
|
11
|
+
|
|
12
|
+
from sphinx_data2table import __version__, setup
|
|
13
|
+
from sphinx_data2table.directive import DataTableDirective
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_rst_with_datatable(rst_content: str) -> nodes.document:
|
|
17
|
+
"""Helper function to parse an RST string.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
rst_content: RST string content.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Docutils document AST.
|
|
24
|
+
"""
|
|
25
|
+
directives.register_directive("data-table", DataTableDirective)
|
|
26
|
+
directives.register_directive("datatable", DataTableDirective)
|
|
27
|
+
parser = Parser()
|
|
28
|
+
settings = get_default_settings(Parser) # type: ignore[arg-type]
|
|
29
|
+
doc = new_document("test.rst", settings)
|
|
30
|
+
parser.parse(rst_content, doc)
|
|
31
|
+
return doc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_sphinx_extension_setup():
|
|
35
|
+
"""Tests the Sphinx extension setup entry point function."""
|
|
36
|
+
mock_app = MagicMock()
|
|
37
|
+
metadata = setup(mock_app)
|
|
38
|
+
|
|
39
|
+
assert metadata["version"] == __version__
|
|
40
|
+
assert metadata["parallel_read_safe"] is True
|
|
41
|
+
assert metadata["parallel_write_safe"] is True
|
|
42
|
+
assert mock_app.add_directive.call_count == 2
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_json_inline_datatable():
|
|
46
|
+
"""Tests parsing JSON inline content into a table."""
|
|
47
|
+
rst = """
|
|
48
|
+
.. data-table::
|
|
49
|
+
:format: json
|
|
50
|
+
|
|
51
|
+
[
|
|
52
|
+
{"name": "**JSON Alpha**", "desc": "First item with `code`"},
|
|
53
|
+
{"name": "*JSON Beta*", "desc": "Second item"}
|
|
54
|
+
]
|
|
55
|
+
"""
|
|
56
|
+
doc = parse_rst_with_datatable(rst)
|
|
57
|
+
tables = list(doc.findall(nodes.table))
|
|
58
|
+
assert len(tables) == 1
|
|
59
|
+
|
|
60
|
+
table = tables[0]
|
|
61
|
+
thead = list(table.findall(nodes.thead))[0]
|
|
62
|
+
headers = [node.astext() for node in thead.findall(nodes.entry)]
|
|
63
|
+
assert headers == ["name", "desc"]
|
|
64
|
+
|
|
65
|
+
tbody = list(table.findall(nodes.tbody))[0]
|
|
66
|
+
rows = list(tbody.findall(nodes.row))
|
|
67
|
+
assert len(rows) == 2
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_yaml_inline_datatable():
|
|
71
|
+
"""Tests parsing YAML inline content into a table."""
|
|
72
|
+
rst = """
|
|
73
|
+
.. datatable::
|
|
74
|
+
:format: yaml
|
|
75
|
+
|
|
76
|
+
- name: "**Alpha**"
|
|
77
|
+
desc: "First item with `code`"
|
|
78
|
+
- name: "*Beta*"
|
|
79
|
+
desc: "Second item"
|
|
80
|
+
"""
|
|
81
|
+
doc = parse_rst_with_datatable(rst)
|
|
82
|
+
tables = list(doc.findall(nodes.table))
|
|
83
|
+
assert len(tables) == 1
|
|
84
|
+
|
|
85
|
+
table = tables[0]
|
|
86
|
+
thead = list(table.findall(nodes.thead))[0]
|
|
87
|
+
headers = [node.astext() for node in thead.findall(nodes.entry)]
|
|
88
|
+
assert headers == ["name", "desc"]
|
|
89
|
+
|
|
90
|
+
tbody = list(table.findall(nodes.tbody))[0]
|
|
91
|
+
rows = list(tbody.findall(nodes.row))
|
|
92
|
+
assert len(rows) == 2
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_toml_inline_datatable():
|
|
96
|
+
"""Tests parsing TOML inline content into a table."""
|
|
97
|
+
rst = """
|
|
98
|
+
.. data-table::
|
|
99
|
+
:format: toml
|
|
100
|
+
|
|
101
|
+
[[items]]
|
|
102
|
+
name = "TOML Item 1"
|
|
103
|
+
val = "100"
|
|
104
|
+
|
|
105
|
+
[[items]]
|
|
106
|
+
name = "TOML Item 2"
|
|
107
|
+
val = "200"
|
|
108
|
+
"""
|
|
109
|
+
doc = parse_rst_with_datatable(rst)
|
|
110
|
+
tables = list(doc.findall(nodes.table))
|
|
111
|
+
assert len(tables) == 1
|
|
112
|
+
|
|
113
|
+
table = tables[0]
|
|
114
|
+
tbody = list(table.findall(nodes.tbody))[0]
|
|
115
|
+
rows = list(tbody.findall(nodes.row))
|
|
116
|
+
assert len(rows) == 2
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_format_auto_detection():
|
|
120
|
+
"""Tests format auto detection for JSON, TOML, and YAML."""
|
|
121
|
+
rst_json = """
|
|
122
|
+
.. data-table::
|
|
123
|
+
|
|
124
|
+
[{"col": "val"}]
|
|
125
|
+
"""
|
|
126
|
+
doc = parse_rst_with_datatable(rst_json)
|
|
127
|
+
assert len(list(doc.findall(nodes.table))) == 1
|
|
128
|
+
|
|
129
|
+
rst_yaml = """
|
|
130
|
+
.. data-table::
|
|
131
|
+
|
|
132
|
+
- col: val
|
|
133
|
+
"""
|
|
134
|
+
doc_y = parse_rst_with_datatable(rst_yaml)
|
|
135
|
+
assert len(list(doc_y.findall(nodes.table))) == 1
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_custom_headers_option():
|
|
139
|
+
"""Tests custom :headers: option overriding dict keys."""
|
|
140
|
+
rst = """
|
|
141
|
+
.. data-table::
|
|
142
|
+
:format: yaml
|
|
143
|
+
:headers: CustomA, CustomB
|
|
144
|
+
|
|
145
|
+
- key1: "Val1"
|
|
146
|
+
key2: "Val2"
|
|
147
|
+
"""
|
|
148
|
+
doc = parse_rst_with_datatable(rst)
|
|
149
|
+
table = list(doc.findall(nodes.table))[0]
|
|
150
|
+
thead = list(table.findall(nodes.thead))[0]
|
|
151
|
+
headers = [node.astext() for node in thead.findall(nodes.entry)]
|
|
152
|
+
assert headers == ["CustomA", "CustomB"]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_external_file_option(tmp_path):
|
|
156
|
+
"""Tests reading data from external file via :file: option."""
|
|
157
|
+
yaml_file = tmp_path / "data.yaml"
|
|
158
|
+
yaml_file.write_text("- name: External\n val: 42\n", encoding="utf-8")
|
|
159
|
+
|
|
160
|
+
rst = f"""
|
|
161
|
+
.. data-table::
|
|
162
|
+
:file: {yaml_file}
|
|
163
|
+
"""
|
|
164
|
+
doc = parse_rst_with_datatable(rst)
|
|
165
|
+
table = list(doc.findall(nodes.table))[0]
|
|
166
|
+
assert len(list(table.findall(nodes.row))) == 2
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_missing_file_error():
|
|
170
|
+
"""Tests error handling for non-existent file."""
|
|
171
|
+
rst = """
|
|
172
|
+
.. data-table::
|
|
173
|
+
:file: non_existent_file_path_xyz.yaml
|
|
174
|
+
"""
|
|
175
|
+
doc = parse_rst_with_datatable(rst)
|
|
176
|
+
errors = list(doc.findall(nodes.system_message))
|
|
177
|
+
assert len(errors) > 0
|
|
178
|
+
assert "Could not read file" in errors[0].astext()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_empty_content_and_file_error():
|
|
182
|
+
"""Tests error handling when neither content nor :file: is provided."""
|
|
183
|
+
rst = """
|
|
184
|
+
.. data-table::
|
|
185
|
+
"""
|
|
186
|
+
doc = parse_rst_with_datatable(rst)
|
|
187
|
+
errors = list(doc.findall(nodes.system_message))
|
|
188
|
+
assert len(errors) > 0
|
|
189
|
+
assert "Neither content nor ':file:'" in errors[0].astext()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_invalid_syntax_error():
|
|
193
|
+
"""Tests error reporting when data contains invalid syntax."""
|
|
194
|
+
rst = """
|
|
195
|
+
.. data-table::
|
|
196
|
+
:format: json
|
|
197
|
+
|
|
198
|
+
{invalid json content
|
|
199
|
+
"""
|
|
200
|
+
doc = parse_rst_with_datatable(rst)
|
|
201
|
+
errors = list(doc.findall(nodes.system_message))
|
|
202
|
+
assert len(errors) > 0
|
|
203
|
+
assert "Failed to parse JSON" in errors[0].astext()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_empty_data_warning():
|
|
207
|
+
"""Tests warning generation when data parses to empty or invalid list."""
|
|
208
|
+
rst = """
|
|
209
|
+
.. data-table::
|
|
210
|
+
:format: yaml
|
|
211
|
+
|
|
212
|
+
"just a string"
|
|
213
|
+
"""
|
|
214
|
+
doc = parse_rst_with_datatable(rst)
|
|
215
|
+
warnings = list(doc.findall(nodes.system_message))
|
|
216
|
+
assert len(warnings) > 0
|
|
217
|
+
assert "Data is empty or invalid format" in warnings[0].astext()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_in_cell_newline_replacement():
|
|
221
|
+
"""Tests that in-cell newlines are replaced with format-safe break nodes."""
|
|
222
|
+
rst = """
|
|
223
|
+
.. data-table::
|
|
224
|
+
:format: yaml
|
|
225
|
+
|
|
226
|
+
- col: "Line 1 \\nLine 2"
|
|
227
|
+
"""
|
|
228
|
+
doc = parse_rst_with_datatable(rst)
|
|
229
|
+
raw_nodes = list(doc.findall(nodes.raw))
|
|
230
|
+
assert len(raw_nodes) >= 1
|
|
231
|
+
assert any(r.astext().strip() == r"\newline" for r in raw_nodes)
|