# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Tests for the ``click:config`` Sphinx directive."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from html import unescape
from textwrap import dedent
import pytest
from click_extra import group
from click_extra.config import CONFIG_PATH_METADATA_KEY
from click_extra.sphinx.click_reference import _format_default, _toml_value
[docs]
@dataclass
class BasketConfig:
"""Nested sub-table exercising the dotted-key expansion."""
apples: int = 3
"""How many apples fit in the basket."""
[docs]
@dataclass
class MarketConfig:
"""Toy schema reused across tests.
Defined at module level (not inside a ``click:source`` block) so
``inspect.getsource`` can recover the attribute docstrings.
"""
city: str = "Lisbon"
"""City where the market is held.
The market moves to the main square during summer, and to the covered
hall the rest of the year.
"""
stalls: list[str] = field(default_factory=lambda: ["fruits", "flowers"])
"""Stall types allowed on the premises."""
basket: BasketConfig = field(
default_factory=BasketConfig,
metadata={CONFIG_PATH_METADATA_KEY: "hand-basket"},
)
"""Parent table docstring, never rendered: only leaves get sections."""
@group(name="market", config_schema=MarketConfig)
def market():
"""Manage the fruit market."""
MARKET_SEED = dedent("""\
```{click:source}
:hide-source:
from tests.sphinx.test_sphinx_click_config import market
```
""")
"""Seed block importing the module-level CLI into the runner namespace."""
[docs]
@dataclass
class PastryConfig:
"""Schema whose keys need slugging and whose default needs escaping."""
creme_brulee: str = field(
default="vanilla|caramel",
metadata={CONFIG_PATH_METADATA_KEY: "crème-brûlée"},
)
"""Flavors of the custard, separated by pipes."""
five_spice: int = field(default=5, metadata={CONFIG_PATH_METADATA_KEY: "5-spice"})
"""How many spices go into the powder."""
PASTRY_CONFIG = dedent("""
```{click:config} PastryConfig
from tests.sphinx.test_sphinx_click_config import PastryConfig
```
""")
"""Directive documenting `PastryConfig`."""
def _plain_text(html: str) -> str:
"""Strip tags and decode entities, so assertions survive Pygments tokenizing.
A highlighted TOML block splits ``city = "Lisbon"`` across ``<span>``
elements; matching on the tag-stripped text keeps the assertions
readable and independent of the lexer's token boundaries.
"""
return unescape(re.sub(r"<[^>]+>", "", html))
[docs]
@pytest.mark.parametrize(
("value", "default", "literal"),
(
(None, "*(none)*", None),
(True, "`true`", "true"),
(False, "`false`", "false"),
(3, "`3`", "3"),
("Lisbon", '`"Lisbon"`', '"Lisbon"'),
("Lisbon\nPorto", "*(see example)*", "'''\nLisbon\nPorto\n'''"),
([], "`[]`", "[]"),
(["figs", "plums"], "`['figs', 'plums']`", '["figs", "plums"]'),
# A list holding a value TOML cannot spell has no example either.
(["figs", None], "`['figs', None]`", None),
# No literal for a value of another type, which renders as it prints.
(2.5, "2.5", None),
),
)
def test_click_config_default_and_toml_literal(value, default, literal):
"""A default renders in the table, and as the literal of its TOML example."""
assert _format_default(value) == default
assert _toml_value(value) == literal
[docs]
def test_click_config_renders_table_and_sections(sphinx_app_myst):
"""The directive expands into a summary table + one section per option."""
content = MARKET_SEED + dedent("""
```{click:config} market
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
# Summary table links every option to its natural heading anchor.
assert 'href="#city"' in html
assert 'href="#stalls"' in html
assert 'href="#hand-basket-apples"' in html
# The headings exist and carry the anchors the table points to.
assert 'id="city"' in html
assert 'id="hand-basket-apples"' in html
# Docstring summaries surface in the table and the sections.
assert "City where the market is held." in html
assert "How many apples fit in the basket." in html
# The docstring's later paragraphs render in the section body.
assert "covered\nhall the rest of the year" in _plain_text(html)
# Type and default lines are rendered with bold labels.
text = _plain_text(html)
assert 'Type: str | Default: "Lisbon"' in text
assert "Type: int | Default: 3" in text
# The parent table's own docstring is not rendered: leaves only.
assert "Parent table docstring" not in html
[docs]
def test_click_config_links_every_option_to_its_section(sphinx_app_myst):
"""Each table link lands on the id docutils gives the option's heading.
`make_id` writes the accented letters of `crème-brûlée` as their ASCII base,
and drops the leading digit of `5-spice`.
"""
html = sphinx_app_myst.build_document(PASTRY_CONFIG)
assert html is not None
table = html[html.index("<table") : html.index("</table>")]
anchors = re.findall(r'href="#([^"]+)"', table)
assert anchors == ["spice", "creme-brulee"]
for anchor in anchors:
assert f'id="{anchor}"' in html
[docs]
def test_click_config_keeps_a_piped_default_in_its_cell(sphinx_app_myst):
"""A pipe in a default value stays text, rather than splitting its row."""
html = sphinx_app_myst.build_document(PASTRY_CONFIG)
assert html is not None
table = _plain_text(html[html.index("<table") : html.index("</table>")])
assert '"vanilla|caramel"' in table
[docs]
def test_click_config_toml_examples(sphinx_app_myst):
"""Each option gets a TOML example pinned to its default value.
The section header defaults to ``tool.{cli-name}``, matching how
click-extra and its downstream CLIs read their section from
``pyproject.toml``.
"""
content = MARKET_SEED + dedent("""
```{click:config} market
```
""")
text = _plain_text(sphinx_app_myst.build_document(content))
assert "[tool.market]" in text
assert 'city = "Lisbon"' in text
assert 'stalls = ["fruits", "flowers"]' in text
assert "hand-basket.apples = 3" in text
[docs]
def test_click_config_section_override_and_suppress(sphinx_app_myst):
"""``:section:`` overrides the example header; an empty value drops it."""
content = MARKET_SEED + dedent("""
```{click:config} market
:section: fruit.stand
```
""")
text = _plain_text(sphinx_app_myst.build_document(content))
assert "[fruit.stand]" in text
assert "[tool.market]" not in text
content = MARKET_SEED + dedent("""
```{click:config} market
:section:
```
""")
text = _plain_text(sphinx_app_myst.build_document(content))
assert 'city = "Lisbon"' in text
assert "[tool.market]" not in text
[docs]
def test_click_config_bare_schema_argument(sphinx_app_myst):
"""A dataclass argument documents the schema without any CLI wired.
Without a CLI there is no name to derive the section header from, so the
examples render bare assignments.
"""
content = dedent("""
```{click:config} MarketConfig
from tests.sphinx.test_sphinx_click_config import MarketConfig
```
""")
text = _plain_text(sphinx_app_myst.build_document(content))
assert "City where the market is held." in text
assert 'city = "Lisbon"' in text
assert "[tool." not in text
[docs]
def test_click_config_no_table_and_no_examples(sphinx_app_myst):
"""``:no-table:`` and ``:no-examples:`` drop their blocks."""
content = MARKET_SEED + dedent("""
```{click:config} market
:no-table:
:no-examples:
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
# Sections are still emitted (each heading keeps its permalink anchor,
# so absence is asserted on the table element, not on hrefs).
assert "City where the market is held." in html
# Summary table is gone: it was the only table on the page.
assert "<table" not in html
# Example blocks are gone.
assert "[tool.market]" not in _plain_text(html)
[docs]
def test_click_config_exec_defined_schema_lacks_docstrings(sphinx_app_myst):
"""A schema defined in an exec'd block renders without descriptions."""
content = dedent("""
```{click:config} InlineConfig
from dataclasses import dataclass
@dataclass
class InlineConfig:
pears: int = 7
\"\"\"Lost to exec.\"\"\"
```
""")
text = _plain_text(sphinx_app_myst.build_document(content))
# Structure renders from the dataclass alone.
assert "pears = 7" in text
assert "Type: int | Default: 7" in text
# The attribute docstring is unrecoverable without source.
assert "Lost to exec" not in text
[docs]
def test_click_config_errors_without_schema(sphinx_app_myst):
"""A CLI with no ``config_schema`` raises a clear directive error."""
content = dedent("""
```{click:source}
:hide-source:
from click_extra import group
@group(name="bare")
def bare():
pass
```
```{click:config} bare
```
""")
with pytest.raises(Exception, match="has no config_schema"):
sphinx_app_myst.build_document(content)
[docs]
def test_click_config_errors_on_non_schema(sphinx_app_myst):
"""Resolving the argument to a non-dataclass raises a clear error."""
content = dedent("""
```{click:source}
:hide-source:
not_a_schema = 42
```
```{click:config} not_a_schema
```
""")
with pytest.raises(Exception, match="did not yield a dataclass schema"):
sphinx_app_myst.build_document(content)
[docs]
def test_click_config_errors_on_unknown_name(sphinx_app_myst):
"""An expression that can't be evaluated raises a clear directive error."""
content = dedent("""
```{click:config} missing_schema
```
""")
with pytest.raises(Exception, match="failed to evaluate"):
sphinx_app_myst.build_document(content)
[docs]
def test_click_config_errors_in_rst(sphinx_app_rst):
"""``click:config`` raises a clear error when used in an rST document."""
content = dedent("""
.. click:config:: market
""")
with pytest.raises(Exception, match="MyST"):
sphinx_app_rst.build_document(content)
[docs]
def test_click_config_heading_offset_adapts_to_surrounding_section(sphinx_app_myst):
"""Nested inside an ``h2`` section, option headings render at ``h3``."""
content = (
dedent("""
# Doc title
## Configuration
""")
+ MARKET_SEED
+ dedent("""
```{click:config} market
```
""")
)
html = sphinx_app_myst.build_document(content)
assert html is not None
assert re.search(r"<h3[^>]*>\s*<code[^>]*>\s*city", html) or "<h3>" in html
assert 'id="city"' in html
[docs]
@dataclass
class OrchardConfig:
"""Schema whose docstrings make Markdown headings, or look like they do."""
trees: int = 12
"""How many trees grow in the orchard.
Pear and apple trees alternate along each row, so no row holds a single
=
kind of fruit.
"""
rows: int = 4
"""How many rows the trees stand in.
# Pruning
Each row is pruned in winter.
"""
seasons: list[str] = field(default_factory=lambda: ["spring", "autumn"])
"""Harvest seasons.
```text
# A comment in a code block
---
```
"""
[docs]
def test_click_config_warns_on_docstring_heading(sphinx_app_myst):
"""Each heading in an option docstring is reported at its docstring line.
The lone `=` makes a setext heading of the paragraph above it, `# Pruning`
is an ATX heading, and the lines of a code block are no heading at all.
"""
content = dedent("""
```{click:config} OrchardConfig
from tests.sphinx.test_sphinx_click_config import OrchardConfig
```
""")
sphinx_app_myst.build_document(content)
warnings = sphinx_app_myst._warning.getvalue()
reported = re.findall(r"line (\d+) of the docstring of option '(\w+)'", warnings)
assert reported == [("3", "rows"), ("4", "trees")]
# The type a project lists in `click_extra_fail_on_warnings`.
assert "[click_extra.config_docstring_heading]" in warnings