# 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:tree`` Sphinx directive."""
from __future__ import annotations
import re
from textwrap import dedent
import pytest
KITCHEN_CLI = dedent("""\
```{click:source}
:hide-source:
from click import command, echo, group, option
@group
def kitchen():
\"\"\"Manage kitchen tools and recipes.\"\"\"
@kitchen.command
@option("--minutes", type=int, default=5)
def boil(minutes):
\"\"\"Boil water for tea.\"\"\"
echo(f"Boiling for {minutes} minutes.")
@kitchen.group
def pantry():
\"\"\"Inspect pantry contents.\"\"\"
@pantry.command
def jars():
\"\"\"List jars on the shelf.\"\"\"
echo("Olives, honey, pickles.")
```
""")
"""Toy CLI reused across tests. Defines a multi-level group so the walk
exercises the recursive descent into nested :class:`click.Group` commands.
"""
[docs]
def test_click_tree_renders_summary_table_and_help_blocks(sphinx_app_myst):
"""The directive expands into a GFM table + one help capture per command."""
content = KITCHEN_CLI + dedent("""
```{click:tree} kitchen
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
# Summary table links every command to its anchor.
assert 'href="#kitchen"' in html
assert 'href="#kitchen-boil"' in html
assert 'href="#kitchen-pantry"' in html
assert 'href="#kitchen-pantry-jars"' in html
# Each command renders its own --help capture as a shell session.
assert "Manage kitchen tools and recipes." in html
assert "Boil water for tea." in html
assert "Inspect pantry contents." in html
assert "List jars on the shelf." in html
# Help screen heading rendered for the root.
assert "Help screen" in html
[docs]
def test_click_tree_inline_import_in_body(sphinx_app_myst):
"""The directive body runs as a preamble, so a seed ``click:source`` is optional."""
content = dedent("""
```{click:tree} demo
:max-depth: 1
from click_extra.cli import demo
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
assert "Help screen" in html
# The bundled `demo` CLI's `cli.name` is "click-extra"; the anchor
# prefix is derived from it, not from the Python variable name.
assert 'href="#click-extra"' in html
[docs]
def test_click_tree_no_table_and_no_root(sphinx_app_myst):
"""``:no-table:`` and ``:no-root:`` drop the summary table and root block."""
content = KITCHEN_CLI + dedent("""
```{click:tree} kitchen
:no-table:
:no-root:
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
# Subcommand sections are still emitted.
assert "Boil water for tea." in html
# Root help heading is gone.
assert "Help screen" not in html
# Summary table is gone: no anchor-linking cells.
assert 'href="#kitchen-boil"' not in html
[docs]
def test_click_tree_max_depth_truncates_walk(sphinx_app_myst):
"""``:max-depth: 1`` stops the walk at one level below the root."""
content = KITCHEN_CLI + dedent("""
```{click:tree} kitchen
:max-depth: 1
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
# Top-level commands still listed.
assert 'href="#kitchen-boil"' in html
assert 'href="#kitchen-pantry"' in html
# Nested subcommand of `pantry` should be skipped: no standalone section,
# no entry in the summary table. The `pantry --help` output naturally
# surfaces a row for `jars` in its own subcommand listing, so we only
# assert on the directive-generated anchor.
assert 'href="#kitchen-pantry-jars"' not in html
assert 'id="kitchen-pantry-jars"' not in html
[docs]
def test_click_tree_label_and_anchor_prefix_override(sphinx_app_myst):
"""``:label-prefix:`` and ``:anchor-prefix:`` override the defaults."""
content = KITCHEN_CLI + dedent("""
```{click:tree} kitchen
:label-prefix: my-kitchen
:anchor-prefix: mk
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
# Custom anchor prefix replaces the default slug.
assert 'href="#mk-boil"' in html
assert 'href="#mk-pantry-jars"' in html
# And the default slug is gone.
assert 'href="#kitchen-boil"' not in html
[docs]
def test_click_tree_summary_keeps_a_piped_description_in_its_cell(sphinx_app_myst):
"""A pipe in a command's summary stays text, rather than splitting its row."""
content = dedent("""
```{click:source}
:hide-source:
from click import command, group
@group
def kitchen():
\"\"\"Manage kitchen tools and recipes.\"\"\"
@kitchen.command
def serve():
\"\"\"Plate the dish on porcelain|slate.\"\"\"
```
```{click:tree} kitchen
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
table = html[html.index("<table") : html.index("</table>")]
assert 'href="#kitchen-serve"' in table
assert "Plate the dish on porcelain|slate" in table
[docs]
def test_click_tree_summary_shows_the_whole_description(sphinx_app_myst):
"""A summary past Click's 45-character cut shows whole in its cell.
`get_short_help_str()` cuts there and appends an ellipsis, and the strip of
the final period removed that ellipsis, which hid the cut. An ellipsis the
author wrote stays.
"""
content = dedent("""
```{click:source}
:hide-source:
from click import group
@group
def kitchen():
\"\"\"Manage kitchen tools and recipes.\"\"\"
@kitchen.command
def steep():
\"\"\"Steep the leaves in water just off the boil for four
minutes, then strain.\"\"\"
@kitchen.command
def wait():
\"\"\"Wait for the kettle...\"\"\"
```
```{click:tree} kitchen
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
table = html[html.index("<table") : html.index("</table>")]
cells = re.findall(r"<td[^>]*><p>(.*?)</p></td>", table)
assert (
"Steep the leaves in water just off the boil for four minutes, then strain"
in cells
)
# Sphinx's smart quotes write the three periods as one ellipsis character.
assert "Wait for the kettle\N{HORIZONTAL ELLIPSIS}" in cells
[docs]
def test_click_tree_links_every_command_to_its_own_section(sphinx_app_myst):
"""Each row links to the id of its section, and no two rows share one.
The anchor is the id docutils' `make_id` derives, which writes the accented
letter of `crème` as its ASCII base. Slugged one name at a time, `make_id`
would also drop the leading digit of `5-spice` and `7-spice`, and give both
the anchor of a `spice` command.
"""
content = dedent("""
```{click:source}
:hide-source:
from click import group
@group
def kitchen():
\"\"\"Manage kitchen tools and recipes.\"\"\"
@kitchen.command(name="crème")
def creme():
\"\"\"Whip the cream.\"\"\"
@kitchen.command(name="5-spice")
def five_spice():
\"\"\"Grind the Chinese blend.\"\"\"
@kitchen.command(name="7-spice")
def seven_spice():
\"\"\"Grind the Lebanese blend.\"\"\"
```
```{click:tree} kitchen
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
table = html[html.index("<table") : html.index("</table>")]
anchors = re.findall(r'href="#([^"]+)"', table)
assert anchors == ["kitchen", "kitchen-5-spice", "kitchen-7-spice", "kitchen-creme"]
for anchor in anchors:
assert f'id="{anchor}"' in html
[docs]
def test_click_tree_walks_subcommands_like_the_tree_option(sphinx_app_myst):
"""The walk lists what ``Group.list_commands`` returns, as ``--tree`` does.
A lazy subcommand is documented although the group never registers it, in
the order the group lists it, and a hidden one is left out.
"""
content = dedent("""
```{click:source}
:hide-source:
from click import Group, command, group
@command
def pour():
\"\"\"Pour the water into the cup.\"\"\"
class Kettle(Group):
def list_commands(self, ctx):
return ["pour", *super().list_commands(ctx)]
def get_command(self, ctx, cmd_name):
if cmd_name == "pour":
return pour
return super().get_command(ctx, cmd_name)
@group(cls=Kettle)
def kettle():
\"\"\"Heat water for tea.\"\"\"
@kettle.command
def boil():
\"\"\"Boil the water.\"\"\"
@kettle.command(hidden=True)
def descale():
\"\"\"Scrub the limescale off.\"\"\"
```
```{click:tree} kettle
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
table = html[html.index("<table") : html.index("</table>")]
assert 'href="#kettle-pour"' in table
assert table.index('href="#kettle-pour"') < table.index('href="#kettle-boil"')
assert 'id="kettle-pour"' in html
assert "Usage: kettle pour [OPTIONS]" in html
assert "kettle-descale" not in html
[docs]
def test_click_tree_errors_on_non_command(sphinx_app_myst):
"""Resolving the argument to a non-Command raises a clear directive error."""
content = dedent("""
```{click:source}
:hide-source:
not_a_cli = 42
```
```{click:tree} not_a_cli
```
""")
with pytest.raises(Exception, match=r"did not yield a click\.Command"):
sphinx_app_myst.build_document(content)
[docs]
def test_click_tree_errors_on_unknown_name(sphinx_app_myst):
"""An expression that can't be evaluated raises a clear directive error."""
content = dedent("""
```{click:tree} missing_cli
```
""")
with pytest.raises(Exception, match="failed to evaluate"):
sphinx_app_myst.build_document(content)
[docs]
def test_click_tree_errors_in_rst(sphinx_app_rst):
"""``click:tree`` raises a clear error when used in an rST document."""
content = dedent("""
.. click:tree:: kitchen
""")
with pytest.raises(Exception, match="MyST"):
sphinx_app_rst.build_document(content)
[docs]
def test_click_tree_heading_offset_defaults_to_top_level(sphinx_app_myst):
"""Below a single ``# Doc title`` the root renders at ``h2``.
A directive in the document body, inside the title's h1 section, emits its
root one level below.
"""
content = (
"# Doc title\n\n"
+ KITCHEN_CLI
+ dedent("""
```{click:tree} kitchen
```
""")
)
html = sphinx_app_myst.build_document(content)
assert html is not None
# Root and top-level commands at h2, second-level at h3.
assert "<h2>Help screen" in html
assert "<h3>" in html and "kitchen pantry jars" in html
[docs]
def test_click_tree_heading_offset_adapts_to_surrounding_section(sphinx_app_myst):
"""Nested inside an ``h3`` section, the root renders at ``h4``.
The default heading offset is computed from
``state.memo.section_level`` so the document outline stays consistent
regardless of where the directive is placed.
"""
content = (
dedent("""
# Top-level title
## Mid-level section
### Deep section
""")
+ KITCHEN_CLI
+ dedent("""
```{click:tree} kitchen
```
""")
)
html = sphinx_app_myst.build_document(content)
assert html is not None
# Root and top-level commands at h4, second-level at h5.
assert "<h4>Help screen" in html
assert "<h5>" in html and "kitchen pantry jars" in html
# No heading-level violation: the tree must not break out to h2 or h3.
assert "<h2>Help screen" not in html
assert "<h3>Help screen" not in html
[docs]
def test_click_tree_heading_offset_explicit_override(sphinx_app_myst):
"""An explicit ``:heading-offset:`` wins over the auto-detected default.
Surrounding headings establish a deep level so the override's effect is
clearly observable in the rendered HTML (without MyST normalizing
skipped heading levels).
"""
content = (
dedent("""
# Top-level title
## Mid-level section
### Deep section
""")
+ KITCHEN_CLI
+ dedent("""
```{click:tree} kitchen
:heading-offset: 2
```
""")
)
html = sphinx_app_myst.build_document(content)
assert html is not None
# Override of 2 pulls the root back to h3 (it would have been h4 with
# the auto-detected offset of 3 from the surrounding ``### Deep section``).
assert "<h3>Help screen" in html
assert "<h4>Help screen" not in html