diff --git a/README.md b/README.md index 28a94d0..9b89c7b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Sphinx directive for documenting dataclass enums in tabular format, with support for enum-properties. -Render [dataclass](https://docs.python.org/3/library/dataclasses.html) enums as tables with a row for each member and a column for every field. Each table has CSV and JSON download buttons. Enums with dataclass values, named tuple values and plain enums work too, and [enum-properties](https://enum-properties.readthedocs.io) enums are supported as a special case: each property becomes a column. +Render [dataclass](https://docs.python.org/3/library/dataclasses.html) enums as tables with a row for each member and a column for every field. Tables can optionally offer CSV and JSON download buttons. Enums with dataclass values, named tuple values and plain enums work too, and [enum-properties](https://enum-properties.readthedocs.io) enums are supported as a special case: each property becomes a column. ## Installation @@ -70,7 +70,7 @@ Which renders a table with `name`, `mass` and `radius` columns. Columns, members :columns: name, radius :members: EARTH, MERCURY :headers: name=Planet, radius=Radius (m) - :download: csv + :download: csv, json ``` ## Documentation diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 123a898..51b3d2f 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -6,5 +6,5 @@ Changelog 0.1.0 (2026-09-28) ------------------ -* Initial release: the ``enum-table`` directive with CSV and JSON downloads. +* Initial release: the ``enum-table`` directive with optional CSV and JSON downloads. * Tables render in PDF (LaTeX) builds, large tables wrap long cells and break across pages. diff --git a/doc/source/conf.py b/doc/source/conf.py index c36332a..f299247 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -44,3 +44,6 @@ # xelatex handles the unicode in the module docstring banner latex_engine = "xelatex" + +# show the download buttons on every example table +enum_table_download = True diff --git a/doc/source/index.rst b/doc/source/index.rst index 150d872..7173c6b 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -54,13 +54,13 @@ sphinxcontrib-enum A Sphinx_ directive for documenting dataclass_ enums in tabular format. Each member is a row and -each dataclass field is a column. Tables can be downloaded as CSV or JSON. +each dataclass field is a column. Tables can optionally be downloaded as CSV or JSON. * Documents enums that mix in a dataclass_ or whose values are dataclasses (or named tuples). * Columns may be any attribute, property or dotted path on the member or its value. * Filter and reorder columns and members, rename headers, add captions and cross references. * Customize how cells render with a formatter function. -* CSV and JSON download buttons (html builders only). +* Optional CSV and JSON download buttons (html builders only). * :ref:`Supports enum-properties ` enums as a special case, and plain enums work too. diff --git a/doc/source/reference/configuration.rst b/doc/source/reference/configuration.rst index c7843f5..1df2e91 100644 --- a/doc/source/reference/configuration.rst +++ b/doc/source/reference/configuration.rst @@ -15,18 +15,28 @@ The following configuration values can be set in your "sphinxcontrib_enum", ] - # only offer json downloads + # offer csv and json downloads for every table (off by default) + enum_table_download = True + + # or only offer json downloads enum_table_download = ["json"] # format cells with a custom function enum_table_formatter = "mypackage.docs.format_cell" .. confval:: enum_table_download - :type: ``list[str]`` - :default: ``["csv", "json"]`` + :type: ``bool | list[str] | str | None`` + :default: ``False`` + + The download formats to offer beneath every table. Downloads are off by default. + + * ``False``, ``None`` or an empty list - no download buttons. + * ``True`` - offer every supported format (``csv`` and ``json``). + * A list of formats, e.g. ``["json"]`` or ``["csv", "json"]``, or the same as a comma + separated string (``"csv, json"``). Buttons are rendered in the order given. - The download formats to offer beneath every table. Set to an empty list to disable downloads. - Can be overridden per table with :rst:dir:`enum-table:download`. + Unsupported formats are a configuration error. Can be overridden per table with + :rst:dir:`enum-table:download`, so individual tables can opt in or out. .. confval:: enum_table_formatter :type: ``str | Callable | None`` diff --git a/doc/source/reference/directive.rst b/doc/source/reference/directive.rst index a18d085..10f9931 100644 --- a/doc/source/reference/directive.rst +++ b/doc/source/reference/directive.rst @@ -89,7 +89,8 @@ Directive **default**: :confval:`enum_table_download` The download formats to offer for this table. Supports ``csv`` and ``json``, or ``none`` to - disable downloads. + disable downloads. Overrides :confval:`enum_table_download`, which is off by default, so + use this to add downloads to individual tables. .. rst:directive:option:: formatter: import path of a cell formatter :type: text diff --git a/doc/source/usage.rst b/doc/source/usage.rst index 592cad2..9344009 100644 --- a/doc/source/usage.rst +++ b/doc/source/usage.rst @@ -120,9 +120,16 @@ to the default formatting: Downloads ========= -HTML builders render download buttons for CSV and JSON versions of the table below it. Other -builders (e.g. LaTeX/PDF, text and epub) omit them. Tables render natively in every -builder, including PDF. +Tables can offer download buttons for CSV and JSON versions of their data. Downloads are off by +default. Turn them on for every table with :confval:`enum_table_download`: + +.. code-block:: python + + # conf.py + enum_table_download = True # or a list of formats, e.g. ["json"] + +HTML builders render the buttons below the table. Other builders (e.g. LaTeX/PDF, text and epub) +omit them. Tables render natively in every builder, including PDF. * **CSV** files contain the header row and the display text of every cell, exactly as rendered. * **JSON** files contain an object keyed by member name. Each member maps to an object keyed by @@ -138,13 +145,13 @@ builder, including PDF. "VENUS": {"mass": 4.869e+24, "radius": 6051800.0, "moons": 0} } -Use :confval:`enum_table_download` to change the default formats for all tables or -:rst:dir:`enum-table:download` for a single table: +Use :rst:dir:`enum-table:download` to override the setting for a single table, either to add +downloads to a table when they are off globally or to remove them when they are on: .. code-block:: rst .. enum-table:: examples.Planet - :download: json + :download: csv, json .. enum-table:: examples.Planet :download: none diff --git a/pyproject.toml b/pyproject.toml index 9ec50c7..5e61255 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", + "Typing :: Typed", "Topic :: Software Development :: Libraries", ] diff --git a/src/sphinxcontrib_enum/directive.py b/src/sphinxcontrib_enum/directive.py index 374121a..17898d3 100644 --- a/src/sphinxcontrib_enum/directive.py +++ b/src/sphinxcontrib_enum/directive.py @@ -14,6 +14,8 @@ from docutils import nodes from docutils.parsers.rst import directives from sphinx.application import Sphinx +from sphinx.config import Config +from sphinx.errors import ConfigError from sphinx.util import logging from sphinx.util.docutils import SphinxDirective, SphinxTranslator from sphinx.util.osutil import ensuredir, relative_uri @@ -34,6 +36,7 @@ "DOWNLOAD_FORMATS", "EnumTableDirective", "Formatter", + "download_formats", "enum_table_downloads", ] @@ -100,9 +103,24 @@ def headers_option(argument: str | None) -> dict[str, str]: return headers -def download_option(argument: str | None) -> list[str]: - formats = [fmt.lower() for fmt in _split(argument)] - if formats in ([], ["none"]): +def download_formats(value: t.Any) -> list[str]: + """ + Normalize a download setting into a list of formats. + + * falsey values (``False``, ``None``, empty) disable downloads + * ``True`` enables every supported format + * strings are comma or whitespace separated formats (or ``none``) + * any other iterable is a collection of formats + + :raises ValueError: If a format is not supported. + """ + if not value: + return [] + if value is True: + return list(DOWNLOAD_FORMATS) + items = _split(value) if isinstance(value, str) else list(value) + formats = [str(fmt).strip().lower() for fmt in items] + if formats == ["none"]: return [] for fmt in formats: if fmt not in DOWNLOAD_FORMATS: @@ -113,6 +131,10 @@ def download_option(argument: str | None) -> list[str]: return list(dict.fromkeys(formats)) +def download_option(argument: str | None) -> list[str]: + return download_formats(argument) + + def widths_option(argument: str | None) -> str | list[int]: if (argument or "").strip().lower() in ("auto", "grid"): return (argument or "").strip().lower() @@ -218,7 +240,9 @@ def run(self) -> list[nodes.Node]: container = nodes.container(classes=["enum-table-container"]) container += table - formats = self.options.get("download", self.config.enum_table_download) + formats = self.options.get( + "download", download_formats(self.config.enum_table_download) + ) if formats: text = [[_text(cell) for cell in row] for row in display] contents = { @@ -450,6 +474,13 @@ def _latex_width_hints(table: nodes.table, rows: list[nodes.row]) -> None: colspec["colwidth"] = round(width) + _LATEX_CELL_PADDING +def _check_download_config(app: Sphinx, config: Config) -> None: + try: + download_formats(config.enum_table_download) + except (TypeError, ValueError) as err: + raise ConfigError(f"Invalid enum_table_download: {err}") from err + + def setup(app: Sphinx) -> None: app.add_node( enum_table_downloads, @@ -458,5 +489,13 @@ def setup(app: Sphinx) -> None: app.add_directive("enum-table", EnumTableDirective) app.connect("doctree-resolved", _remove_downloads) app.connect("doctree-resolved", _wrap_latex_longtables) - app.add_config_value("enum_table_download", list(DOWNLOAD_FORMATS), "env") + app.add_config_value( + "enum_table_download", + False, + "env", + # frozenset/set must not be listed, sphinx converts sequences to frozensets + # when they are, losing the format order + types=(bool, list, tuple, str, type(None)), + ) + app.connect("config-inited", _check_download_config) app.add_config_value("enum_table_formatter", None, "env") diff --git a/tests/test.py b/tests/test.py index c9189e4..bf7ccd6 100644 --- a/tests/test.py +++ b/tests/test.py @@ -11,6 +11,7 @@ from bs4 import BeautifulSoup from pypdf import PdfReader from sphinx.application import Sphinx +from sphinx.errors import ConfigError from sphinx.util.console import nocolor from sphinx.util.docutils import docutils_namespace @@ -183,7 +184,9 @@ def test_import_enum(): def test_enum_properties_table(tmp_path): - out, warnings = build(tmp_path, ".. enum-table:: tests.enums.Color\n") + out, warnings = build( + tmp_path, ".. enum-table:: tests.enums.Color\n", enum_table_download=True + ) assert not warnings page = soup(out) headers, rows = table_data(page) @@ -232,7 +235,9 @@ def test_enum_properties_table(tmp_path): def test_csv_escaping(tmp_path): - out, warnings = build(tmp_path, ".. enum-table:: tests.enums.Level\n") + out, warnings = build( + tmp_path, ".. enum-table:: tests.enums.Level\n", enum_table_download=True + ) assert not warnings page = soup(out) headers, rows = table_data(page) @@ -247,7 +252,9 @@ def test_csv_escaping(tmp_path): def test_dataclass_mixin_table(tmp_path): - out, warnings = build(tmp_path, ".. enum-table:: tests.enums.Planet\n") + out, warnings = build( + tmp_path, ".. enum-table:: tests.enums.Planet\n", enum_table_download=True + ) assert not warnings page = soup(out) headers, rows = table_data(page) @@ -268,6 +275,7 @@ def test_dataclass_value_table(tmp_path): .. enum-table:: tests.enums.ColorValue :columns: name value """, + enum_table_download=True, ) assert not warnings page = soup(out) @@ -320,6 +328,7 @@ def test_options(tmp_path): See :numref:`color-table` and :ref:`color-table`. """, numfig=True, + enum_table_download=True, ) assert not warnings page = soup(out) @@ -354,6 +363,7 @@ def test_json_keyed_without_name_column(tmp_path): .. enum-table:: tests.enums.Color :columns: hex """, + enum_table_download=True, ) assert not warnings page = soup(out) @@ -408,13 +418,62 @@ def test_download_option(tmp_path): assert set(downloads(out, page, 1)) == {"Plain.csv"} -def test_download_disabled_globally(tmp_path): +@pytest.mark.parametrize( + "conf", + [ + {}, # disabled by default + {"enum_table_download": False}, + {"enum_table_download": None}, + {"enum_table_download": []}, + {"enum_table_download": ()}, + {"enum_table_download": ""}, + {"enum_table_download": "none"}, + ], +) +def test_download_disabled_globally(tmp_path, conf): out, warnings = build( - tmp_path, ".. enum-table:: tests.enums.Plain\n", enum_table_download=[] + tmp_path, + """ + .. enum-table:: tests.enums.Plain + + .. enum-table:: tests.enums.Plain + :download: json + """, + **conf, ) assert not warnings - assert not soup(out).select("div.enum-table-downloads") - assert not (out / "_downloads").exists() + page = soup(out) + containers = page.select("div.enum-table-container") + assert not containers[0].select("div.enum-table-downloads") + # tables can still opt in when downloads are disabled globally + assert set(downloads(out, page)) == {"Plain.json"} + + +@pytest.mark.parametrize( + "setting, expected", + [ + (True, ["Plain.csv", "Plain.json"]), + (["json"], ["Plain.json"]), + (("JSON", "csv"), ["Plain.json", "Plain.csv"]), + ("json, csv", ["Plain.json", "Plain.csv"]), + (["json", "csv"], ["Plain.json", "Plain.csv"]), + ], +) +def test_download_config_values(tmp_path, setting, expected): + out, warnings = build( + tmp_path, ".. enum-table:: tests.enums.Plain\n", enum_table_download=setting + ) + assert not warnings + links = soup(out).select("a.enum-table-download") + assert [link["download"] for link in links] == expected + + +@pytest.mark.parametrize("setting", [["xml"], "csv yaml", ["none", "csv"]]) +def test_download_config_invalid(tmp_path, setting): + with pytest.raises(ConfigError, match="Invalid enum_table_download"): + build( + tmp_path, ".. enum-table:: tests.enums.Plain\n", enum_table_download=setting + ) def test_download_links_relative(tmp_path): @@ -429,6 +488,7 @@ def test_download_links_relative(tmp_path): tmp_path, ".. toctree::\n\n sub/deeper/page\n", builder=builder, + enum_table_download=True, ) assert not warnings page = soup(out, page_path) @@ -439,7 +499,10 @@ def test_download_links_relative(tmp_path): def test_singlehtml(tmp_path): out, warnings = build( - tmp_path, ".. enum-table:: tests.enums.Plain\n", builder="singlehtml" + tmp_path, + ".. enum-table:: tests.enums.Plain\n", + builder="singlehtml", + enum_table_download=True, ) assert not warnings page = soup(out) @@ -471,6 +534,7 @@ def test_formatters(tmp_path): :formatter: tests.enums:format_paragraph """, enum_table_formatter="tests.enums.format_hex", + enum_table_download=True, ) assert not warnings page = soup(out) @@ -504,6 +568,7 @@ def test_formatter_json_fallback(tmp_path): :columns: name parent :formatter: tests.test:format_parent """, + enum_table_download=True, ) assert not warnings page = soup(out) @@ -597,7 +662,10 @@ def test_empty_enum(tmp_path): def test_text_builder(tmp_path): out, warnings = build( - tmp_path, ".. enum-table:: tests.enums.Color\n", builder="text" + tmp_path, + ".. enum-table:: tests.enums.Color\n", + builder="text", + enum_table_download=True, ) assert not warnings text = (out / "index.txt").read_text() @@ -607,7 +675,10 @@ def test_text_builder(tmp_path): def test_latex_builder(tmp_path): out, warnings = build( - tmp_path, ".. enum-table:: tests.enums.Planet\n", builder="latex" + tmp_path, + ".. enum-table:: tests.enums.Planet\n", + builder="latex", + enum_table_download=True, ) assert not warnings tex = next(out.glob("*.tex")).read_text() @@ -656,6 +727,7 @@ def test_epub_builder(tmp_path): ".. enum-table:: tests.enums.Plain\n", builder="epub", epub_copyright="test", + enum_table_download=True, ) assert "enum-table-download" not in (out / "index.xhtml").read_text() assert not (out / "_downloads").exists() @@ -748,6 +820,7 @@ def test_pdf_build(tmp_path, engine): """, engine, numfig=True, + enum_table_download=True, ) assert not warnings assert "Missing character" not in log