sphinx-data2table 0.1.1__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.
- sphinx_data2table/__init__.py +35 -0
- sphinx_data2table/directive.py +333 -0
- sphinx_data2table-0.1.1.dist-info/METADATA +12 -0
- sphinx_data2table-0.1.1.dist-info/RECORD +7 -0
- sphinx_data2table-0.1.1.dist-info/WHEEL +5 -0
- sphinx_data2table-0.1.1.dist-info/licenses/LICENSE +21 -0
- sphinx_data2table-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -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,7 @@
|
|
|
1
|
+
sphinx_data2table/__init__.py,sha256=RKatWID8p2r1j8EFCPGYHqfEsN-NZaI72ww_XJb8D0Q,916
|
|
2
|
+
sphinx_data2table/directive.py,sha256=Abs0f86-BKadBbLhrPEuRbjfeJi3ui8ktYGS6bNoyII,11492
|
|
3
|
+
sphinx_data2table-0.1.1.dist-info/licenses/LICENSE,sha256=fH3Y9McptuMlBOJaKKnH0MWywtXzzXru8txKerv5S0s,1072
|
|
4
|
+
sphinx_data2table-0.1.1.dist-info/METADATA,sha256=fw2nncuUVTlZt59iYD6uEH1giWc_iISIqxhZqO1oJok,363
|
|
5
|
+
sphinx_data2table-0.1.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
6
|
+
sphinx_data2table-0.1.1.dist-info/top_level.txt,sha256=iDTbSqriAxdWeBFybs0MreaEBfZ3cBVUEZItg2zZ40w,18
|
|
7
|
+
sphinx_data2table-0.1.1.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
sphinx_data2table
|