# 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 Sphinx directives click:source and click:run in rST and MyST formats."""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
from textwrap import dedent
import click
import pytest
from docutils import nodes
from click_extra.screenshot import CAPTURE_BACKGROUND, LIGHT_CAPTURE_BACKGROUND
from click_extra.sphinx.click import (
_CLIRUNNER_HAS_CAPTURE,
SCREENSHOT_MARKER_END,
SCREENSHOT_MARKER_START,
ClickRunner,
_rewrite_screenshot_regions,
_screenshot_background,
_screenshot_columns,
_screenshot_opacity,
program_from_command_line,
)
from .conftest import HTML, DirectiveTestCase, FormatType, format_params
# Test case definitions
BASIC_DIRECTIVES_TEST_CASE = DirectiveTestCase(
# Test minimal documents with directives in both RST and MyST formats.
name="basic",
source_block="""
from click import command, echo
@command
def simple_cli():
echo("It works!")
""",
run_block="invoke(simple_cli)",
html_matches=(
(
HTML["python_highlight"]
+ HTML["import_click"]
+ "\n"
+ '<span class="nd">@command</span>\n'
+ '<span class="k">def</span><span class="w"> </span><span class="nf">simple_cli</span><span class="p">():</span>\n'
+ ' <span class="n">echo</span><span class="p">(</span><span class="s2">"It works!"</span><span class="p">)</span>\n'
+ "</pre></div>\n"
),
(
HTML["shell_session"]
+ '<span class="gp">$ </span>simple-cli\n'
+ "It works!\n"
+ "</pre></div>\n"
),
),
)
LINENOS_TEST_CASE = DirectiveTestCase(
# Test that :linenos: option adds line numbers to code blocks.
name="linenos",
source_block="""
:linenos:
from click import command, echo
@command
def numbered_example():
echo("Line numbers should appear")
echo("on the left side")
""",
run_block="""
:linenos:
invoke(numbered_example)
""",
html_matches=(
(
HTML["python_highlight"]
+ '<span class="linenos">1</span>'
+ HTML["import_click"]
+ '<span class="linenos">2</span>\n'
+ '<span class="linenos">3</span><span class="nd">@command</span>\n'
+ '<span class="linenos">4</span><span class="k">def</span><span class="w"> </span><span class="nf">numbered_example</span><span class="p">():</span>\n'
+ '<span class="linenos">5</span> <span class="n">echo</span><span class="p">(</span><span class="s2">"Line numbers should appear"</span><span class="p">)</span>\n'
+ '<span class="linenos">6</span> <span class="n">echo</span><span class="p">(</span><span class="s2">"on the left side"</span><span class="p">)</span>\n'
+ "</pre></div>\n"
),
(
HTML["shell_session"]
+ '<span class="linenos">1</span><span class="gp">$ </span>numbered-example\n'
+ '<span class="linenos">2</span>Line numbers should appear\n'
+ '<span class="linenos">3</span>on the left side\n'
+ "</pre></div>\n"
),
),
)
LINENOS_START_TEST_CASE = DirectiveTestCase(
# Test that :lineno-start: shifts the starting line number.
name="linenos_start",
source_block="""
:linenos:
:lineno-start: 5
from click import command, echo
@command
def numbered_example():
echo("Line numbers should start from 5")
echo("and continue incrementing")
""",
run_block="""
:linenos:
:lineno-start: 10
invoke(numbered_example)
""",
html_matches=(
(
HTML["python_highlight"]
+ '<span class="linenos"> 5</span>'
+ HTML["import_click"]
+ '<span class="linenos"> 6</span>\n'
+ '<span class="linenos"> 7</span><span class="nd">@command</span>\n'
+ '<span class="linenos"> 8</span><span class="k">def</span><span class="w"> </span><span class="nf">numbered_example</span><span class="p">():</span>\n'
+ '<span class="linenos"> 9</span> <span class="n">echo</span><span class="p">(</span><span class="s2">"Line numbers should start from 5"</span><span class="p">)</span>\n'
+ '<span class="linenos">10</span> <span class="n">echo</span><span class="p">(</span><span class="s2">"and continue incrementing"</span><span class="p">)</span>\n'
+ "</pre></div>\n"
),
(
HTML["shell_session"]
+ '<span class="linenos">10</span><span class="gp">$ </span>numbered-example\n'
+ '<span class="linenos">11</span>Line numbers should start from 5\n'
+ '<span class="linenos">12</span>and continue incrementing\n'
+ "</pre></div>\n"
),
),
)
EMPHASIZE_LINES_TEST_CASE = DirectiveTestCase(
# Test that :emphasize-lines: applies to source only and
# :emphasize-result-lines: applies to results only, independently.
name="emphasize_lines_split",
source_block="""
from click import command, echo
@command
def two_liner():
echo("first line")
echo("second line")
""",
run_block="""
:show-source:
:emphasize-lines: 1
:emphasize-result-lines: 3
invoke(two_liner)
""",
html_matches=(
# Source code-block from click:source has no emphasis.
(
HTML["python_highlight"]
+ HTML["import_click"]
+ "\n"
+ '<span class="nd">@command</span>\n'
+ '<span class="k">def</span><span class="w"> </span><span class="nf">two_liner</span><span class="p">():</span>\n'
+ ' <span class="n">echo</span><span class="p">(</span><span class="s2">"first line"</span><span class="p">)</span>\n'
+ ' <span class="n">echo</span><span class="p">(</span><span class="s2">"second line"</span><span class="p">)</span>\n'
+ "</pre></div>\n"
),
# Run directive's source block: line 1 (the only line) is highlighted.
(
HTML["python_highlight"]
+ '<span class="hll"><span class="n">invoke</span><span class="p">(</span><span class="n">two_liner</span><span class="p">)</span>\n</span>'
+ "</pre></div>\n"
),
# Run directive's result block: line 3 ("second line") is highlighted,
# not the prompt or "first line".
(
HTML["shell_session"]
+ '<span class="gp">$ </span>two-liner\n'
+ "first line\n"
+ '<span class="hll">second line\n</span>'
+ "</pre></div>\n"
),
),
)
HIDE_SOURCE_TEST_CASE = DirectiveTestCase(
# Test that :hide-source: hides source code in click:source directive.
name="hide_source",
source_block="""
:hide-source:
from click import command, echo
@command
def simple_print():
echo("Just a string to print.")
""",
run_block="invoke(simple_print)",
# Check from the start of the body to make sure the click:source is gone.
html_matches=' <div class="body" role="main">\n'
+ " \n "
+ HTML["shell_session"]
+ '<span class="gp">$ </span>simple-print\n'
+ "Just a string to print.\n"
+ "</pre></div>\n",
)
SHOW_SOURCE_TEST_CASE = DirectiveTestCase(
# Test that :show-source: option shows source code in click:run directive.
name="show_source",
source_block="""
from click import command, echo
@command
def simple_print():
echo("Just a string to print.")
""",
run_block="""
:show-source:
invoke(simple_print)
""",
html_matches=(
# Source directive should show source.
(
HTML["python_highlight"]
+ HTML["import_click"]
+ "\n"
+ '<span class="nd">@command</span>\n'
+ '<span class="k">def</span><span class="w"> </span><span class="nf">simple_print</span><span class="p">():</span>\n'
+ ' <span class="n">echo</span><span class="p">(</span><span class="s2">"Just a string to print."</span><span class="p">)</span>\n'
+ "</pre></div>\n"
),
# Run directive should show source code.
(
HTML["python_highlight"]
+ '<span class="n">invoke</span><span class="p">(</span><span class="n">simple_print</span><span class="p">)</span>\n'
+ "</pre></div>\n"
),
# Run directive should show execution results.
(
HTML["shell_session"]
+ '<span class="gp">$ </span>simple-print\n'
+ "Just a string to print.\n"
+ "</pre></div>\n"
+ "</div>\n"
),
),
)
HIDE_RESULTS_TEST_CASE = DirectiveTestCase(
# Test that :hide-results: option hides execution results in click:run directive.
name="hide_results",
source_block="""
from click import command, echo
@command
def simple_print():
echo("Just a string to print.")
""",
run_block="""
:hide-results:
invoke(simple_print)
""",
# Source directive should show source.
html_matches=HTML["python_highlight"]
+ HTML["import_click"]
+ "\n"
+ '<span class="nd">@command</span>\n'
+ '<span class="k">def</span><span class="w"> </span><span class="nf">simple_print</span><span class="p">():</span>\n'
+ ' <span class="n">echo</span><span class="p">(</span><span class="s2">"Just a string to print."</span><span class="p">)</span>\n'
+ "</pre></div>\n",
)
SHOW_RESULTS_TEST_CASE = DirectiveTestCase(
# Test that :show-results: option shows execution results (default behavior).
name="show_results",
source_block="""
from click import command, echo
@command
def simple_print():
echo("Just a string to print.")
""",
run_block="""
:show-results:
invoke(simple_print)
""",
html_matches=(
# Source directive should show source.
(
HTML["python_highlight"]
+ HTML["import_click"]
+ "\n"
+ '<span class="nd">@command</span>\n'
+ '<span class="k">def</span><span class="w"> </span><span class="nf">simple_print</span><span class="p">():</span>\n'
+ ' <span class="n">echo</span><span class="p">(</span><span class="s2">"Just a string to print."</span><span class="p">)</span>\n'
+ "</pre></div>\n"
),
# Run directive should show execution results.
(
HTML["shell_session"]
+ '<span class="gp">$ </span>simple-print\n'
+ "Just a string to print.\n"
+ "</pre></div>\n"
),
),
)
OPTION_COMBINATIONS_TEST_CASE = DirectiveTestCase(
# Test various combinations of display options.
name="option_combinations",
source_block="""
:show-source:
:hide-results:
from click import command, echo
@command
def simple_print():
echo("Just a string to print.")
""",
run_block="""
:show-source:
:hide-results:
:show-results:
invoke(simple_print)
""",
html_matches=(
# Source directive should show source.
(
HTML["python_highlight"]
+ HTML["import_click"]
+ "\n"
+ '<span class="nd">@command</span>\n'
+ '<span class="k">def</span><span class="w"> </span><span class="nf">simple_print</span><span class="p">():</span>\n'
+ ' <span class="n">echo</span><span class="p">(</span><span class="s2">"Just a string to print."</span><span class="p">)</span>\n'
+ "</pre></div>\n"
),
# Run directive should show source code.
(
HTML["python_highlight"]
+ '<span class="n">invoke</span><span class="p">(</span><span class="n">simple_print</span><span class="p">)</span>\n'
+ "</pre></div>\n"
),
# Run directive should show execution results (show-results overrides
# hide-results).
(
HTML["shell_session"]
+ '<span class="gp">$ </span>simple-print\n'
+ "Just a string to print.\n"
+ "</pre></div>\n"
),
),
)
MIXED_OUTPUT_TEST_CASE = DirectiveTestCase(
# Test directives that print to both stdout and stderr with proper rendering.
name="mixed_output",
source_block="""
import sys
from click import command, echo
from click_extra import style, Color
@command
def mixed_output():
echo(f"This goes to {style('stdout', fg=Color.blue)}")
echo(f"This is an {style('error', fg=Color.red)}", err=True)
print(f"Direct {style('stdout', fg=Color.blue)} print", file=sys.stdout)
print(f"Direct {style('stderr', fg=Color.red)} print", file=sys.stderr)
""",
run_block="invoke(mixed_output)",
# Should show mixed stdout/stderr output with colors.
html_matches=HTML["shell_session"]
+ '<span class="gp">$ </span>mixed-output\n'
+ 'This goes to <span class="-Ansi -Ansi-Blue">stdout</span>\n'
+ 'This is an <span class="-Ansi -Ansi-Red">error</span>\n'
+ 'Direct <span class="-Ansi -Ansi-Blue">stdout</span> print\n'
+ 'Direct <span class="-Ansi -Ansi-Red">stderr</span> print\n'
+ "</pre></div>",
)
ISOLATED_FILESYSTEM_TEST_CASE = DirectiveTestCase(
# Test that isolated_filesystem works properly in click:run directives.
name="isolated_filesystem",
source_block="""
from click import command, echo
@command
def greet():
echo("Hello World!")
""",
run_block="""
with isolated_filesystem():
with open("test.txt", "w") as f:
f.write("Hello File!")
invoke(greet)
""",
# Should show command execution within isolated filesystem.
html_matches=HTML["shell_session"]
+ '<span class="gp">$ </span>greet\n'
+ "Hello World!\n"
+ "</pre></div>\n",
)
RST_WITHIN_MYST_EVAL_TEST_CASE = DirectiveTestCase(
name="rst_within_myst_eval",
# This test is MyST-specific but contains embedded RST.
format_type=FormatType.MYST,
document="""
```{eval-rst}
.. click:source::
from click import command, echo
@command
def yo_cli():
echo("Yo!")
.. click:run::
invoke(yo_cli)
```
""",
html_matches=(
(
HTML["python_highlight"]
+ HTML["import_click"]
+ "\n"
+ '<span class="nd">@command</span>\n'
+ '<span class="k">def</span><span class="w"> </span><span class="nf">yo_cli</span><span class="p">():</span>\n'
+ ' <span class="n">echo</span><span class="p">(</span><span class="s2">"Yo!"</span><span class="p">)</span>\n'
+ "</pre></div>\n"
),
(
HTML["shell_session"]
+ '<span class="gp">$ </span>yo-cli\n'
+ "Yo!\n"
+ "</pre></div>\n"
),
),
)
[docs]
@pytest.mark.parametrize(
("sphinx_app_for_format", "test_case"),
format_params(
BASIC_DIRECTIVES_TEST_CASE,
EMPHASIZE_LINES_TEST_CASE,
LINENOS_TEST_CASE,
LINENOS_START_TEST_CASE,
HIDE_SOURCE_TEST_CASE,
SHOW_SOURCE_TEST_CASE,
HIDE_RESULTS_TEST_CASE,
SHOW_RESULTS_TEST_CASE,
OPTION_COMBINATIONS_TEST_CASE,
MIXED_OUTPUT_TEST_CASE,
ISOLATED_FILESYSTEM_TEST_CASE,
RST_WITHIN_MYST_EVAL_TEST_CASE,
),
indirect=["sphinx_app_for_format"],
)
def test_directive_functionality(sphinx_app_for_format, test_case):
"""Test standard directive functionalities in each format a case targets."""
content = sphinx_app_for_format.generate_test_content(test_case)
html_output = sphinx_app_for_format.build_document(content)
# Assert all expected fragments are present.
for fragment in test_case.html_matches:
assert fragment in html_output
[docs]
def test_directive_option_language_override(sphinx_app):
"""Test that language override works for click:run directive."""
format_type = sphinx_app.format_type
if format_type == FormatType.RST:
content = dedent("""
.. click:source::
from click import command, echo, option
@command
@option("--name")
def sql_output(name):
sql_query = f"SELECT * FROM users WHERE name = '{name}';"
echo(sql_query)
.. click:run:: sql
invoke(sql_output, args=["--name", "Joe"])
""")
elif format_type == FormatType.MYST:
content = dedent("""
```{click:source}
from click import command, echo, option
@command
@option("--name")
def sql_output(name):
sql_query = f"SELECT * FROM users WHERE name = '{name}';"
echo(sql_query)
```
```{click:run} sql
invoke(sql_output, args=["--name", "Joe"])
```
""")
html_output = sphinx_app.build_document(content)
assert (
HTML["sql_highlight"]
+ '<span class="err">$</span><span class="w"> </span><span class="k">sql</span><span class="o">-</span><span class="k">output</span><span class="w"> </span><span class="c1">--name Joe</span>\n'
+ '<span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'Joe'</span><span class="p">;</span>\n'
+ "</pre></div>\n"
) in html_output
[docs]
def test_sphinx_directive_state_persistence(sphinx_app):
"""Test that state persists between declare and run directives in real Sphinx."""
format_type = sphinx_app.format_type
if format_type == FormatType.RST:
content = dedent("""
.. click:source::
from click import command, echo
@command
def cmd1():
echo("Command 1")
.. click:source::
@command
def cmd2():
echo("Command 2")
.. click:run::
invoke(cmd1)
.. click:run::
invoke(cmd2)
""")
elif format_type == FormatType.MYST:
content = dedent("""
```{click:source}
from click import command, echo
@command
def cmd1():
echo("Command 1")
```
```{click:source}
@command
def cmd2():
echo("Command 2")
```
```{click:run}
invoke(cmd1)
```
```{click:run}
invoke(cmd2)
```
""")
html_output = sphinx_app.build_document(content)
assert (
HTML["shell_session"]
+ '<span class="gp">$ </span>cmd1\n'
+ "Command 1\n"
+ "</pre></div>\n"
) in html_output
assert (
HTML["shell_session"]
+ '<span class="gp">$ </span>cmd2\n'
+ "Command 2\n"
+ "</pre></div>\n"
) in html_output
[docs]
@pytest.mark.parametrize("var_name", ["invoke", "isolated_filesystem"])
@pytest.mark.parametrize(
("sphinx_app_for_format", "content", "directive_lineno", "error_lineno"),
[
# Test variable conflicts in both rST and MyST formats.
(
FormatType.RST,
"""\
.. click:run::
# This should fail due to variable conflict.
{var_name} = "Do not overwrite me!"
""",
1,
4,
),
(
FormatType.MYST,
"""\
```{{click:run}}
# This should fail due to variable conflict.
{var_name} = "Do not overwrite me!"
```
""",
1,
3,
),
# Check proper line number reporting with preceding lines.
(
FormatType.RST,
"""
.. click:source::
from click import command, echo
@command
def hello():
echo("Hello World!")
.. click:run::
# This should fail due to variable conflict.
{var_name} = "Do not overwrite me!"
result = invoke(hello)
""",
9 + 4,
12 + 4,
),
(
FormatType.MYST,
"""
```{{click:source}}
from click import command, echo
@command
def hello():
echo("Hello World!")
```
```{{click:run}}
# This should fail due to variable conflict.
{var_name} = "Do not overwrite me!"
result = invoke(hello)
```
""",
9 + 4,
11 + 4,
),
# Check proper line number reporting with blank lines within the directive.
(
FormatType.RST,
"""\
.. click:run::
# This should fail due to variable conflict.
{var_name} = "Do not overwrite me!"
""",
1,
4,
),
(
FormatType.MYST,
"""\
```{{click:run}}
# This should fail due to variable conflict.
{var_name} = "Do not overwrite me!"
```
""",
1,
3,
),
(
FormatType.RST,
"""\
.. click:run::
# This should fail due to variable conflict.
{var_name} = "Do not overwrite me!"
""",
1,
6,
),
(
FormatType.MYST,
"""\
```{{click:run}}
# This should fail due to variable conflict.
{var_name} = "Do not overwrite me!"
```
""",
1,
5,
),
# Options should not affect line numbering.
(
FormatType.RST,
"""\
.. click:run::
:linenos:
# This should fail due to variable conflict.
{var_name} = "Do not overwrite me!"
""",
1,
5,
),
(
FormatType.MYST,
"""\
```{{click:run}}
:linenos:
# This should fail due to variable conflict.
{var_name} = "Do not overwrite me!"
```
""",
1,
4,
),
(
FormatType.RST,
"""\
.. click:run::
:linenos:
:lineno-start: 10
# This should fail due to variable conflict.
{var_name} = "Do not overwrite me!"
""",
1,
6,
),
(
FormatType.MYST,
"""\
```{{click:run}}
:linenos:
:lineno-start: 10
# This should fail due to variable conflict.
{var_name} = "Do not overwrite me!"
```
""",
1,
5,
),
(
FormatType.RST,
"""\
.. click:run::
:linenos:
# This should fail due to variable conflict.
{var_name} = "Do not overwrite me!"
""",
1,
7,
),
(
FormatType.MYST,
"""\
```{{click:run}}
:linenos:
# This should fail due to variable conflict.
{var_name} = "Do not overwrite me!"
```
""",
1,
6,
),
(
FormatType.RST,
"""\
.. click:run::
:linenos:
# This should fail due to variable conflict.
{var_name} = "Do not overwrite me!"
""",
1,
5,
),
(
FormatType.MYST,
"""\
```{{click:run}}
:linenos:
# This should fail due to variable conflict.
{var_name} = "Do not overwrite me!"
```
""",
1,
4,
),
],
indirect=["sphinx_app_for_format"],
)
def test_directive_variable_conflict(
var_name, sphinx_app_for_format, content, directive_lineno, error_lineno
):
"""Test that variable conflicts are properly detected in real Sphinx environment."""
format_type = sphinx_app_for_format.format_type
content = dedent(content).format(var_name=var_name)
with pytest.raises(RuntimeError) as exc_info:
sphinx_app_for_format.build_document(content)
file_extension = format_type.value
expected_pattern = (
rf"Local variable '{var_name}' at .+index"
+ re.escape(file_extension)
+ rf":{directive_lineno}:click:run:{error_lineno} "
+ r"conflicts with the one automatically provided by the click:run directive\.\n"
rf"Line: {var_name} = \"Do not overwrite me!\""
)
assert re.fullmatch(expected_pattern, str(exc_info.value))
GENERATED_BLOCK_ANCHOR = "from click_extra import command, echo"
"""First body line of the ``GENERATED_BLOCK_LINE_CASES`` documents below.
The line a generated block is expected to be attributed to, located in the
document itself so editing a case moves the expectation with it.
"""
GENERATED_BLOCK_LINE_CASES = format_params(
DirectiveTestCase(
name="plain-body",
format_type=FormatType.RST,
document=f"""
Title
=====
Filler paragraph.
.. click:run::
:emphasize-result-lines: 1
{GENERATED_BLOCK_ANCHOR}
@command
def hello():
echo("Hello")
invoke(hello, args=[])
""",
),
DirectiveTestCase(
name="plain-body",
format_type=FormatType.MYST,
document=f"""
# Title
Filler paragraph.
```{{click:run}}
:emphasize-result-lines: 1
{GENERATED_BLOCK_ANCHOR}
@command
def hello():
echo("Hello")
invoke(hello, args=[])
```
""",
),
DirectiveTestCase(
name="body-ending-on-blank-lines",
format_type=FormatType.RST,
document=f"""
Title
=====
Filler paragraph.
.. click:run::
:emphasize-result-lines: 1
{GENERATED_BLOCK_ANCHOR}
@command
def hello():
echo("Hello")
invoke(hello, args=[])
Trailer paragraph.
""",
),
DirectiveTestCase(
name="body-ending-on-blank-lines",
format_type=FormatType.MYST,
document=f"""
# Title
Filler paragraph.
```{{click:run}}
:emphasize-result-lines: 1
{GENERATED_BLOCK_ANCHOR}
@command
def hello():
echo("Hello")
invoke(hello, args=[])
```
""",
),
)
[docs]
@pytest.mark.parametrize(
("sphinx_app_for_format", "test_case"),
GENERATED_BLOCK_LINE_CASES,
indirect=["sphinx_app_for_format"],
)
def test_generated_block_is_attributed_to_the_directive_body(
sphinx_app_for_format, test_case
):
"""A block a directive generates is attributed to its first body line.
The generated lines exist nowhere in the document, so the parser has to be
told which document line to hang a diagnostic raised inside them on. Both
formats must answer the same way, and neither is free to fall back on its
own default: docutils numbers an unlabelled block from the top of the
*file*, and ``myst-parser`` measures its offsets from the directive rather
than the document.
The last two cases pin the ``content_offset`` inflation
``click_extra.sphinx.click.MYST_CONTENT_OFFSET_INFLATED_MAX`` documents,
which only fires on a directive carrying both an option block and a body
ending in blank lines.
"""
document = test_case.document
sphinx_app_for_format.build_document(document)
expected_line = next(
number
for number, line in enumerate(document.splitlines(), start=1)
if line.strip() == GENERATED_BLOCK_ANCHOR
)
generated = sphinx_app_for_format.env.get_doctree("index").findall(
nodes.literal_block
)
assert [block.line for block in generated] == [expected_line]
[docs]
def test_exit_exception_percolate(sphinx_app):
"""Test directives that handle command errors and exit codes."""
format_type = sphinx_app.format_type
if format_type == FormatType.RST:
content = dedent("""
.. click:source::
import sys
from click import command, echo, option
@command
@option('--fail', is_flag=True, help='Force command to fail')
def error_command(fail):
echo("Starting command...")
if fail:
echo("Something went wrong!", err=True)
sys.exit(1)
echo("Command completed successfully")
.. click:run::
# Test successful execution
invoke(error_command, [])
.. click:run::
# Test failed execution
try:
invoke(error_command, ['--fail'])
except SystemExit as e:
echo(f"Command exited with code: {e.code}", err=True)
""")
elif format_type == FormatType.MYST:
content = dedent("""
```{click:source}
import sys
from click import command, echo, option
@command
@option('--fail', is_flag=True, help='Force command to fail')
def error_command(fail):
echo("Starting command...")
if fail:
echo("Something went wrong!", err=True)
sys.exit(1)
echo("Command completed successfully")
```
```{click:run}
# Test successful execution
invoke(error_command, [])
```
```{click:run}
# Test failed execution
try:
invoke(error_command, ['--fail'])
except SystemExit as e:
echo(f"Command exited with code: {e.code}", err=True)
```
""")
html_output = sphinx_app.build_document(content)
assert (
HTML["shell_session"]
+ '<span class="gp">$ </span>error<span class="w"> </span>--fail\n'
+ "Starting command...\n"
+ "Something went wrong!\n"
+ "</pre></div>"
) in html_output
[docs]
def test_clickrunner_forces_color(monkeypatch):
"""``ClickRunner`` forces ``FORCE_COLOR`` so Rich-based CLIs colorize under ``NO_COLOR``.
The runner already passes ``color=True`` (Click's color system). But rich-click
renders help through Rich's ``Console``, gated on ``FORCE_COLOR``, which ``color=True``
never reaches. The runner therefore also forces ``FORCE_COLOR`` (clearing the
disabling vars) around the executed command, then restores the environment.
"""
monkeypatch.setenv("NO_COLOR", "1")
monkeypatch.delenv("FORCE_COLOR", raising=False)
seen = {}
@click.command()
def probe():
seen["FORCE_COLOR"] = os.environ.get("FORCE_COLOR")
seen["NO_COLOR"] = os.environ.get("NO_COLOR")
ClickRunner().invoke(probe, [])
# Color was forced through Rich's system while the command ran...
assert seen["FORCE_COLOR"] == "1"
assert seen["NO_COLOR"] is None
# ...and the build environment is restored afterwards.
assert os.environ.get("FORCE_COLOR") is None
assert os.environ["NO_COLOR"] == "1"
[docs]
@pytest.mark.skipif(
not _CLIRUNNER_HAS_CAPTURE,
reason="Click < 8.4 has no capture mode for ClickRunner to select.",
)
@pytest.mark.parametrize(
("capture", "renders"),
(
# No argument: defaults to "fd" on Unix (renders) and "sys" on Windows (no render).
pytest.param(None, sys.platform != "win32"),
# Explicit "fd": not supported on Windows (fd-backed streams require Unix fds).
pytest.param(
"fd",
True,
marks=pytest.mark.skipif(
sys.platform == "win32",
reason='capture="fd" is not supported on Windows.',
),
),
("sys", False),
),
)
def test_clickrunner_capture_mode_controls_fileno(capture, renders):
"""``ClickRunner(capture=...)`` decides whether a fileno-writing CLI renders.
Click's ``"sys"`` mode backs the captured stream with an in-memory buffer whose
``fileno()`` raises :exc:`io.UnsupportedOperation`, so a documented command that
re-opens its descriptor (a common UTF-8-on-Windows guard) aborts. ``"fd"`` (the
default on Unix, also exposed as the ``click_extra_run_capture`` conf.py value)
backs it with a real descriptor, so the command renders. On Windows, where fd-backed
streams are not supported, the default falls back to ``"sys"``.
"""
@click.command()
def probe():
# A real CLI might re-open this descriptor to force an encoding before writing.
sys.stdout.fileno()
click.echo("papaya")
result = ClickRunner(capture=capture).invoke(probe, [])
if renders:
assert result.exit_code == 0
assert "papaya" in result.output
else:
assert result.exit_code != 0
assert "papaya" not in result.output
[docs]
@pytest.mark.parametrize(
("command_line", "expected"),
(
# A single word is the program, interpreter or not.
("my-cli", "my-cli"),
("python", "python"),
# Every word of a multi-word program belongs to it.
("click-extra wrap", "click-extra wrap"),
("git remote add", "git remote add"),
# An interpreter prefix names no program.
("python -m my_cli", "my_cli"),
("python3 -m my_cli", "my_cli"),
("python3.14 -m my_cli", "my_cli"),
("/usr/bin/python3 -m my_cli", "my_cli"),
("pypy3 -m my_cli", "my_cli"),
),
)
def test_program_from_command_line(command_line, expected):
"""Only an interpreter prefix is dropped from a displayed command line."""
assert program_from_command_line(command_line) == expected
[docs]
def test_clickrunner_keeps_a_multi_word_prog_name(monkeypatch):
"""A subcommand-shaped program name reaches the command it runs, whole."""
monkeypatch.setenv("FORCE_COLOR", "0")
@click.group()
def parent():
pass
@parent.command()
def child():
"""Do something."""
click.echo(click.get_current_context().command_path)
lines: list[str] = []
ClickRunner().invoke(
child,
args=[],
prog_name="parent child",
_output_lines=lines,
)
assert lines[0] == "$ parent child"
assert lines[1] == "parent child"
[docs]
def test_clickrunner_prompt_inlines_environment_assignments(monkeypatch):
"""Variables are set on the invocation, the way the runner applies them.
`CliRunner` scopes `env` to the one call, so an `export` line would advertise
a persistence the next block does not inherit.
"""
monkeypatch.setenv("FORCE_COLOR", "0")
@click.command()
def forecast():
click.echo("18 degrees.")
lines: list[str] = []
ClickRunner().invoke(
forecast,
env={"WEATHER_UNITS": "celsius", "WEATHER_CITY": "Paris"},
_output_lines=lines,
)
assert lines[0] == "$ WEATHER_CITY=Paris WEATHER_UNITS=celsius forecast"
[docs]
def test_clickrunner_prompt_quotes_a_spaced_argument(monkeypatch):
"""An argument holding spaces stays the single token a reader must type."""
monkeypatch.setenv("FORCE_COLOR", "0")
@click.command()
@click.option("--city")
def forecast(city):
click.echo(city)
lines: list[str] = []
ClickRunner().invoke(
forecast,
args=["--city", "Rio de Janeiro"],
_output_lines=lines,
)
assert lines[0] == "$ forecast --city 'Rio de Janeiro'"
[docs]
def test_clickrunner_hide_prompt_drops_the_invocation(monkeypatch):
"""`_show_prompt=False` leaves the output alone and drops the line above it."""
monkeypatch.setenv("FORCE_COLOR", "0")
@click.command()
def forecast():
click.echo("18 degrees.")
lines: list[str] = []
ClickRunner().invoke(
forecast,
env={"WEATHER_UNITS": "celsius"},
_output_lines=lines,
_show_prompt=False,
)
assert lines == ["18 degrees."]
[docs]
@pytest.mark.parametrize(
("options", "prompted"),
(
pytest.param("", True, id="default"),
pytest.param(":show-prompt:\n", True, id="show-prompt"),
pytest.param(":hide-prompt:\n", False, id="hide-prompt"),
# Last occurrence wins, mirroring the source and results flags.
pytest.param(":hide-prompt:\n:show-prompt:\n", True, id="hide-then-show"),
),
)
def test_click_run_prompt_options(sphinx_app_myst, options, prompted):
"""`:show-prompt:` / `:hide-prompt:` gate the invocation line."""
html = sphinx_app_myst.build_document(
dedent("""
```{{click:source}}
from click_extra import command, echo
@command
def greet():
echo("Hello, papaya!")
```
```{{click:run}}
{options}result = invoke(greet)
```
""").format(options=options)
)
assert html
# The output is rendered either way; only the prompt above it moves.
assert "Hello, papaya!" in html
assert ('<span class="gp">$ </span>greet' in html) is prompted
[docs]
def test_click_run_hide_prompt_reaches_the_screenshot(sphinx_app_myst):
"""A capture is drawn from the same lines, so it loses the prompt too."""
sphinx_app_myst.build_document(
dedent("""
```{click:source}
from click_extra import command, echo
@command
def greet():
echo("Hello, papaya!")
```
```{click:run}
:hide-prompt:
:screenshot: bare-greet-screen
:screenshot-preset: windows
result = invoke(greet)
```
""")
)
asset = Path(sphinx_app_myst.app.srcdir) / "assets" / "bare-greet-screen.svg"
svg = asset.read_text(encoding="utf-8")
assert "Hello," in svg
# Neither this platform's sigil nor the one the preset would have swapped in.
assert "$ greet" not in svg
assert "PS C:\\>" not in svg
[docs]
def test_click_run_screenshot_writes_the_asset(sphinx_app_myst):
"""``:screenshot:`` writes the capture beside the documentation.
The image is a side effect: the page keeps its results code block, which
inside Sphinx beats an image by staying selectable and searchable.
"""
html_output = sphinx_app_myst.build_document(
dedent("""
```{click:source}
from click_extra import command, echo
@command
def greet():
echo("Hello, papaya!")
```
```{click:run}
:screenshot: greet-screen
result = invoke(greet)
```
""")
)
asset = Path(sphinx_app_myst.app.srcdir) / "assets" / "greet-screen.svg"
assert asset.exists(), "the capture was not written"
svg = asset.read_text(encoding="utf-8")
# `unique_id` is pinned to the asset name, so a regenerated capture diffs
# line by line instead of renaming every CSS class.
assert "greet-screen-r1" in svg
assert "papaya" in svg
# The results block is still rendered, rather than swapped for the image.
assert "papaya" in html_output
[docs]
def test_click_run_screenshot_background(sphinx_app_myst):
"""``:screenshot-background:`` draws the capture on the chrome it names."""
sphinx_app_myst.build_document(
dedent("""
```{click:source}
from click_extra import command, echo
@command
def greet():
echo("Hello, papaya!")
```
```{click:run}
:screenshot: pale-greet-screen
:screenshot-background: light
result = invoke(greet)
```
""")
)
asset = Path(sphinx_app_myst.app.srcdir) / "assets" / "pale-greet-screen.svg"
svg = asset.read_text(encoding="utf-8")
assert LIGHT_CAPTURE_BACKGROUND in svg
assert CAPTURE_BACKGROUND not in svg
[docs]
def test_click_run_screenshot_columns_auto(sphinx_app_myst):
"""``:screenshot-columns: auto`` widens the image to what the block printed.
Click wraps a CLI's own text at its fixed width whatever the option says.
What it decides is the picture, and a line the CLI never wrapped (the prompt
of a long invocation) is the one that needs the room.
"""
source = dedent("""
```{{click:source}}
from click_extra import command, echo
@command
def chant():
echo("papaya " * 20)
```
```{{click:run}}
:screenshot: {name}
{options}
result = invoke(chant)
```
""")
assets = Path(sphinx_app_myst.app.srcdir) / "assets"
sphinx_app_myst.build_document(
source.format(name="pinned-chant-screen", options="")
)
sphinx_app_myst.build_document(
source.format(name="wide-chant-screen", options=":screenshot-columns: auto")
)
widths = {}
for name in ("pinned-chant-screen", "wide-chant-screen"):
svg = (assets / f"{name}.svg").read_text(encoding="utf-8")
match = re.search(r'viewBox="0 0 (?P<width>[\d.]+)', svg)
assert match
widths[name] = float(match["width"])
assert widths["wide-chant-screen"] > widths["pinned-chant-screen"]
[docs]
@pytest.mark.parametrize("value", ("beige", "0", "-3", "12"))
def test_click_run_screenshot_columns_rejects_an_unusable_width(value):
"""A width narrower than the floor, or no width at all, is a build error."""
with pytest.raises(ValueError):
_screenshot_columns(value)
[docs]
def test_click_run_screenshot_frame_options(sphinx_app_myst):
"""The `:screenshot-*:` options restate the window, the title included.
The hex color is quoted because a directive's options are read as YAML,
where an unquoted `#` opens a comment and leaves the option empty.
"""
sphinx_app_myst.build_document(
dedent("""
```{click:source}
from click_extra import command, echo
@command
def greet():
echo("Hello, papaya!")
```
```{click:run}
:screenshot: framed-greet-screen
:screenshot-title: greeter
:screenshot-backdrop: "#1f6feb"
:screenshot-border: red
:screenshot-border-width: 3
:screenshot-radius: 0
result = invoke(greet)
```
""")
)
asset = Path(sphinx_app_myst.app.srcdir) / "assets" / "framed-greet-screen.svg"
svg = asset.read_text(encoding="utf-8")
assert 'stroke="red"' in svg
assert 'stroke-width="3"' in svg
assert 'rx="0"' in svg
assert 'fill="#1f6feb"' in svg
assert "greeter" in svg
[docs]
def test_click_run_screenshot_preset_swaps_the_prompt(sphinx_app_myst):
"""A capture drawn as another terminal prompts the way that one does.
A block runs under a documentation build, so its own prompt is this
platform's `$`. The picture is of a Windows terminal, whose shell prompts
with something else entirely.
"""
sphinx_app_myst.build_document(
dedent("""
```{click:source}
from click_extra import command, echo
@command
def greet():
echo("Hello, papaya!")
```
```{click:run}
:screenshot: windows-greet-screen
:screenshot-preset: windows
result = invoke(greet)
```
""")
)
asset = Path(sphinx_app_myst.app.srcdir) / "assets" / "windows-greet-screen.svg"
svg = asset.read_text(encoding="utf-8")
assert "PS C:\\> greet" in svg
assert "Cascadia Code" in svg
# Campbell's background, and the square corners Windows draws.
assert 'fill="#0c0c0c"' in svg
assert 'rx="0"' in svg
[docs]
def test_click_run_screenshot_preset_defaults_to_the_project_wide_one(sphinx_app_myst):
"""A project drawing every capture as the same terminal states it once."""
sphinx_app_myst.app.config.click_extra_screenshot_preset = "linux"
sphinx_app_myst.build_document(
dedent("""
```{click:source}
from click_extra import command, echo
@command
def greet():
echo("Hello, papaya!")
```
```{click:run}
:screenshot: gnome-greet-screen
result = invoke(greet)
```
""")
)
asset = Path(sphinx_app_myst.app.srcdir) / "assets" / "gnome-greet-screen.svg"
svg = asset.read_text(encoding="utf-8")
assert "Ubuntu Mono" in svg
# Tango's dark background, and the strip GNOME paints over it.
assert 'fill="#2e3436"' in svg
assert 'fill="#303030"' in svg
[docs]
def test_click_run_screenshot_carries_no_watermark_by_default(sphinx_app_myst):
"""A capture a build rewrites carries no release number to go stale.
The `screenshot` command credits click-extra on every image it writes. A
block's image is regenerated and committed on every build, so the same mark
would rewrite every asset the day the release it names changes.
"""
sphinx_app_myst.build_document(
dedent("""
```{click:source}
from click_extra import command, echo
@command
def greet():
echo("Hello, papaya!")
```
```{click:run}
:screenshot: unmarked-greet-screen
result = invoke(greet)
```
""")
)
asset = Path(sphinx_app_myst.app.srcdir) / "assets" / "unmarked-greet-screen.svg"
assert "watermark" not in asset.read_text(encoding="utf-8")
[docs]
def test_click_run_screenshot_watermark(sphinx_app_myst):
"""A project wanting its captures credited states the line once."""
sphinx_app_myst.app.config.click_extra_screenshot_watermark = "pantry 1.4.2"
sphinx_app_myst.build_document(
dedent("""
```{click:source}
from click_extra import command, echo
@command
def greet():
echo("Hello, papaya!")
```
```{click:run}
:screenshot: credited-greet-screen
result = invoke(greet)
```
```{click:run}
:screenshot: recredited-greet-screen
:screenshot-watermark: shot on a Tuesday
result = invoke(greet)
```
""")
)
assets = Path(sphinx_app_myst.app.srcdir) / "assets"
project_wide = (assets / "credited-greet-screen.svg").read_text(encoding="utf-8")
assert '<text class="watermark"' in project_wide
assert "pantry 1.4.2" in project_wide
# A block naming its own mark keeps it.
per_block = (assets / "recredited-greet-screen.svg").read_text(encoding="utf-8")
assert "shot on a Tuesday" in per_block
assert "pantry 1.4.2" not in per_block
[docs]
def test_click_run_screenshot_opacity(sphinx_app_myst):
"""A see-through window lets the page it is laid on show through its body."""
sphinx_app_myst.build_document(
dedent("""
```{click:source}
from click_extra import command, echo
@command
def greet():
echo("Hello, papaya!")
```
```{click:run}
:screenshot: glassy-greet-screen
:screenshot-opacity: 0.4
result = invoke(greet)
```
""")
)
asset = Path(sphinx_app_myst.app.srcdir) / "assets" / "glassy-greet-screen.svg"
assert 'fill-opacity="0.4"' in asset.read_text(encoding="utf-8")
[docs]
@pytest.mark.parametrize("argument", ("-0.1", "1.5", "opaque"))
def test_click_run_screenshot_opacity_rejects_what_is_not_one(argument):
"""Anything outside the zero-to-one range fails the build instead of clamping."""
with pytest.raises(ValueError):
_screenshot_opacity(argument)
[docs]
def test_click_run_screenshot_background_rejects_an_unknown_chrome():
"""A typo names the chromes it could have been, instead of drawing a default."""
with pytest.raises(ValueError, match=r'"dark".+"light"'):
_screenshot_background("beige")
[docs]
def test_click_run_mirror_region_round_trips():
"""``:mirror:`` inserts an image link below the fence, then leaves it alone."""
source = dedent("""
# Title
```{click:run}
:screenshot: greet-screen
:mirror:
result = invoke(greet)
```
Trailing prose.
""")
once = _rewrite_screenshot_regions(source)
assert SCREENSHOT_MARKER_START in once
assert "" in once
assert SCREENSHOT_MARKER_END in once
assert "Trailing prose." in once
# Idempotent: a second pass over an already-refreshed region is a no-op.
assert _rewrite_screenshot_regions(once) == once
[docs]
def test_click_run_mirror_needs_both_options():
"""A block missing either option gets no region.
``:screenshot:`` alone maintains an asset some other surface embeds, without
putting it on this page; ``:mirror:`` alone has no capture to point at.
"""
for options in (":screenshot: lone-screen", ":mirror:"):
source = dedent(f"""
```{{click:run}}
{options}
result = invoke(greet)
```
""")
assert _rewrite_screenshot_regions(source) == source
[docs]
def test_click_run_mirror_skips_a_nested_example():
"""A ``click:run`` shown inside a longer fence is documentation, not a block."""
source = dedent("""
````markdown
```{click:run}
:screenshot: nested-screen
:mirror:
result = invoke(greet)
```
````
""")
assert _rewrite_screenshot_regions(source) == source