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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ pyyaml>=6.0.3
2
+ sphinx>=8.0
@@ -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)