MyST-Parser icon indicating copy to clipboard operation
MyST-Parser copied to clipboard

tests/test_sphinx/test_sphinx_builds.py::test_basic fails if pytest_param_files is not in the environment

Open befeleme opened this issue 3 years ago • 6 comments

Describe the bug

context When I build MyST-Parser 0.18.0 for Fedora 37 with Python3.11~b3, Sphinx 5.0.2, docutils 0.18.1 I run a test suite with available components. I disable tests requiring linkify and pytest_param_files, as we don't have those packages available.

That's pretty much the whole incantation:

pytest -k  'not test_extended_syntaxes' --ignore 'tests/test_renderers/test_fixtures_sphinx.py' --ignore 'tests/test_renderers/test_fixtures_docutils.py' --deselect 'tests/test_html/test_html_to_nodes.py::test_html_to_nodes' --deselect 'tests/test_html/test_parse_html.py::test_html_ast' --deselect 'tests/test_html/test_parse_html.py::test_html_round_trip' --deselect 'tests/test_renderers/test_error_reporting.py::test_basic' --deselect 'tests/test_renderers/test_include_directive.py::test_render' --deselect 'tests/test_renderers/test_include_directive.py::test_errors' --deselect 'tests/test_renderers/test_myst_config.py::test_cmdline' --deselect 'tests/test_renderers/test_parse_directives.py::test_parsing'

expectation I expected tests to pass.

bug But instead one test fails. test_basic_sphinx which doesn't require linkify, nor pytest_param_files fixture. It fails repeatedly with the same problem.

app = <SphinxTestApp buildername='html'>
status = <_io.StringIO object at 0x7fd9cfe05870>
warning = <_io.StringIO object at 0x7fd9cfe05a20>
get_sphinx_app_doctree = <function get_sphinx_app_doctree.<locals>.read at 0x7fd9cfcf94e0>
get_sphinx_app_output = <function get_sphinx_app_output.<locals>.read at 0x7fd9cfcf9620>

    @pytest.mark.sphinx(
        buildername="html",
        srcdir=os.path.join(SOURCE_DIR, "basic"),
        freshenv=True,
        confoverrides={"myst_enable_extensions": ["dollarmath"]},
    )
    def test_basic(
        app,
        status,
        warning,
        get_sphinx_app_doctree,
        get_sphinx_app_output,
    ):
        """basic test."""
        app.build()
    
        assert "build succeeded" in status.getvalue()  # Build succeeded
        warnings = warning.getvalue().strip()
        assert warnings == ""
    
        try:
>           get_sphinx_app_doctree(
                app,
                docname="content",
                regress=True,
            )

tests/test_sphinx/test_sphinx_builds.py:41: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

app = <SphinxTestApp buildername='html'>, docname = 'content', resolve = False
regress = True, replace = None, regress_ext = '.xml'

    def read(
        app,
        docname="index",
        resolve=False,
        regress=False,
        replace=None,
        regress_ext=".xml",
    ):
        if resolve:
            doctree = app.env.get_and_resolve_doctree(docname, app.builder)
            extension = f".resolved{regress_ext}"
        else:
            doctree = app.env.get_doctree(docname)
            extension = regress_ext
    
        # convert absolute filenames
        for node in findall(doctree)(
            lambda n: "source" in n and not isinstance(n, str)
        ):
            node["source"] = pathlib.Path(node["source"]).name
    
        if regress:
            text = doctree.pformat()  # type: str
            for find, rep in (replace or {}).items():
                text = text.replace(find, rep)
>           file_regression.check(text, extension=extension)

tests/test_sphinx/conftest.py:123: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <pytest_regressions.file_regression.FileRegressionFixture object at 0x7fd9cfcaa3d0>
contents = '<document source="content.md">\n    <topic classes="dedication">\n        <title>\n            Dedication\n        <p...tution references:\n        <paragraph>\n            57\n             words | \n            0\n             min read\n'
encoding = None, extension = '.xml', newline = None, basename = None
fullpath = None, binary = False, obtained_filename = None
check_fn = functools.partial(<function check_text_files at 0x7fd9d03aa660>, encoding=None)

    def check(
        self,
        contents,
        encoding=None,
        extension=".txt",
        newline=None,
        basename=None,
        fullpath=None,
        binary=False,
        obtained_filename=None,
        check_fn=None,
    ):
        """
        Checks the contents against a previously recorded version, or generate a new file.
    
        :param str contents: content to be verified.
        :param str|None encoding: Encoding used to write file, if any.
        :param str extension: Extension of file.
        :param str|None newline: See `io.open` docs.
        :param bool binary: If the file is binary or text.
        :param obtained_filename: ..see:: FileRegressionCheck
        :param check_fn: a function with signature ``(obtained_filename, expected_filename)`` that should raise
            AssertionError if both files differ.
            If not given, use internal function which compares text using :py:mod:`difflib`.
        """
        __tracebackhide__ = True
    
        if binary and encoding:
            raise ValueError(
                "Only binary ({!r}) or encoding ({!r}) parameters must be passed at the same time.".format(
                    binary, encoding
                )
            )
    
        if binary:
            assert isinstance(
                contents, bytes
            ), "Expected bytes contents but received type {}".format(
                type(contents).__name__
            )
        else:
            assert isinstance(
                contents, str
            ), "Expected text/unicode contents but received type {}".format(
                type(contents).__name__
            )
    
        import io
    
        if check_fn is None:
    
            if binary:
    
                def check_fn(obtained_filename, expected_filename):
                    if obtained_filename.read_bytes() != expected_filename.read_bytes():
                        raise AssertionError(
                            "Binary files {} and {} differ.".format(
                                obtained_filename, expected_filename
                            )
                        )
    
            else:
                check_fn = partial(check_text_files, encoding=encoding)
    
        def dump_fn(filename):
            mode = "wb" if binary else "w"
            with open(str(filename), mode, encoding=encoding, newline=newline) as f:
                f.write(contents)
    
>       perform_regression_check(
            datadir=self.datadir,
            original_datadir=self.original_datadir,
            request=self.request,
            check_fn=check_fn,
            dump_fn=dump_fn,
            extension=extension,
            basename=basename,
            fullpath=fullpath,
            force_regen=self.force_regen,
            with_test_class_names=self.with_test_class_names,
            obtained_filename=obtained_filename,
        )

/usr/lib/python3.11/site-packages/pytest_regressions/file_regression.py:93: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

datadir = PosixPath('/tmp/pytest-of-mockbuild/pytest-0/test_basic0/test_sphinx_builds')
original_datadir = PosixPath('/builddir/build/BUILD/MyST-Parser-0.18.0/tests/test_sphinx/test_sphinx_builds')
request = <SubRequest 'file_regression' for <Function test_basic>>
check_fn = functools.partial(<function check_text_files at 0x7fd9d03aa660>, encoding=None)
dump_fn = <function FileRegressionFixture.check.<locals>.dump_fn at 0x7fd9cfbb2f20>
extension = '.xml', basename = 'test_basic', fullpath = None
force_regen = False, with_test_class_names = False
obtained_filename = PosixPath('/tmp/pytest-of-mockbuild/pytest-0/test_basic0/test_sphinx_builds/test_basic.obtained.xml')
dump_aux_fn = <function <lambda> at 0x7fd9d03aa7a0>

    def perform_regression_check(
        datadir,
        original_datadir,
        request,
        check_fn,
        dump_fn,
        extension,
        basename=None,
        fullpath=None,
        force_regen=False,
        with_test_class_names=False,
        obtained_filename=None,
        dump_aux_fn=lambda filename: [],
    ):
        """
        First run of this check will generate a expected file. Following attempts will always try to
        match obtained files with that expected file.
    
        If expected file needs to be updated, just enable `force_regen` argument.
    
        :param Path datadir: Fixture embed_data.
        :param Path original_datadir: Fixture embed_data.
        :param SubRequest request: Pytest request object.
        :param callable check_fn: A function that receives as arguments, respectively, absolute path to
            obtained file and absolute path to expected file. It must assert if contents of file match.
            Function can safely assume that obtained file is already dumped and only care about
            comparison.
        :param callable dump_fn: A function that receive an absolute file path as argument. Implementor
            must dump file in this path.
        :param callable dump_aux_fn: A function that receives the same file path as ``dump_fn``, but may
            dump additional files to help diagnose this regression later (for example dumping image of
            3d views and plots to compare later). Must return the list of file names written (used to display).
        :param str extension: Extension of files compared by this check.
        :param bool force_regen: if true it will regenerate expected file.
        :param bool with_test_class_names: if true it will use the test class name (if any) to compose
            the basename.
        :param str obtained_filename: complete path to use to write the obtained file. By
            default will prepend `.obtained` before the file extension.
        ..see: `data_regression.Check` for `basename` and `fullpath` arguments.
        """
        import re
    
        assert not (basename and fullpath), "pass either basename or fullpath, but not both"
    
        __tracebackhide__ = True
    
        with_test_class_names = with_test_class_names or request.config.getoption(
            "with_test_class_names"
        )
        if basename is None:
            if (request.node.cls is not None) and (with_test_class_names):
                basename = re.sub(r"[\W]", "_", request.node.cls.__name__) + "_"
            else:
                basename = ""
            basename += re.sub(r"[\W]", "_", request.node.name)
    
        if fullpath:
            filename = source_filename = Path(fullpath)
        else:
            filename = datadir / (basename + extension)
            source_filename = original_datadir / (basename + extension)
    
        def make_location_message(banner, filename, aux_files):
            msg = [banner, f"- {filename}"]
            if aux_files:
                msg.append("Auxiliary:")
                msg += [f"- {x}" for x in aux_files]
            return "\n".join(msg)
    
        force_regen = force_regen or request.config.getoption("force_regen")
        if not filename.is_file():
            source_filename.parent.mkdir(parents=True, exist_ok=True)
            dump_fn(source_filename)
            aux_created = dump_aux_fn(source_filename)
    
            msg = make_location_message(
                "File not found in data directory, created:", source_filename, aux_created
            )
            pytest.fail(msg)
        else:
            if obtained_filename is None:
                if fullpath:
                    obtained_filename = (datadir / basename).with_suffix(
                        ".obtained" + extension
                    )
                else:
                    obtained_filename = filename.with_suffix(".obtained" + extension)
    
            dump_fn(obtained_filename)
    
            try:
>               check_fn(obtained_filename, filename)

/usr/lib/python3.11/site-packages/pytest_regressions/common.py:162: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

obtained_fn = PosixPath('/tmp/pytest-of-mockbuild/pytest-0/test_basic0/test_sphinx_builds/test_basic.obtained.xml')
expected_fn = PosixPath('/tmp/pytest-of-mockbuild/pytest-0/test_basic0/test_sphinx_builds/test_basic.xml')
fix_callback = <function <lambda> at 0x7fd9d03aa520>, encoding = None

    def check_text_files(obtained_fn, expected_fn, fix_callback=lambda x: x, encoding=None):
        """
        Compare two files contents. If the files differ, show the diff and write a nice HTML
        diff file into the data directory.
    
        :param Path obtained_fn: path to obtained file during current testing.
    
        :param Path expected_fn: path to the expected file, obtained from previous testing.
    
        :param str encoding: encoding used to open the files.
    
        :param callable fix_callback:
            A callback to "fix" the contents of the obtained (first) file.
            This callback receives a list of strings (lines) and must also return a list of lines,
            changed as needed.
            The resulting lines will be used to compare with the contents of expected_fn.
        """
        __tracebackhide__ = True
    
        obtained_fn = Path(obtained_fn)
        expected_fn = Path(expected_fn)
        obtained_lines = fix_callback(obtained_fn.read_text(encoding=encoding).splitlines())
        expected_lines = expected_fn.read_text(encoding=encoding).splitlines()
    
        if obtained_lines != expected_lines:
            diff_lines = list(
                difflib.unified_diff(expected_lines, obtained_lines, lineterm="")
            )
            if len(diff_lines) <= 500:
                html_fn = obtained_fn.with_suffix(".diff.html")
                try:
                    differ = difflib.HtmlDiff()
                    html_diff = differ.make_file(
                        fromlines=expected_lines,
                        fromdesc=expected_fn,
                        tolines=obtained_lines,
                        todesc=obtained_fn,
                    )
                except Exception as e:
                    html_fn = "(failed to generate html diff: %s)" % e
                else:
                    html_fn.write_text(html_diff, encoding="UTF-8")
    
                diff = ["FILES DIFFER:", str(expected_fn), str(obtained_fn)]
                diff += ["HTML DIFF: %s" % html_fn]
                diff += diff_lines
>               raise AssertionError("\n".join(diff))
E               AssertionError: FILES DIFFER:
E               /tmp/pytest-of-mockbuild/pytest-0/test_basic0/test_sphinx_builds/test_basic.xml
E               /tmp/pytest-of-mockbuild/pytest-0/test_basic0/test_sphinx_builds/test_basic.obtained.xml
E               HTML DIFF: /tmp/pytest-of-mockbuild/pytest-0/test_basic0/test_sphinx_builds/test_basic.obtained.diff.html
E               --- 
E               +++ 
E               @@ -53,7 +53,7 @@
E                                https://www.google.com
E                        <paragraph>
E                            <strong>
E               -                <literal classes="code">
E               +                <literal classes="code" language="">
E                                     a=1{`} 
E                        <paragraph>
E                            <math>

/usr/lib/python3.11/site-packages/pytest_regressions/common.py:57: AssertionError

During handling of the above exception, another exception occurred:
<snip - this is the same output for the `finally` block there>

tests/test_sphinx/conftest.py:123: AssertionError
--------------------------- Captured stdout teardown ---------------------------
# testroot: root
# builder: html
# srcdir: /builddir/build/BUILD/MyST-Parser-0.18.0/tests/test_sphinx/sourcedirs/basic
# outdir: /builddir/build/BUILD/MyST-Parser-0.18.0/tests/test_sphinx/sourcedirs/basic/_build/html
# status: 
Running Sphinx v5.0.2
myst v0.18.0: MdParserConfig(commonmark_only=False, gfm_only=False, enable_extensions=['dollarmath'], disable_syntax=[], all_links_external=False, url_schemes=('http', 'https', 'mailto', 'ftp'), ref_domains=None, highlight_code_blocks=True, number_code_blocks=[], title_to_header=False, heading_anchors=None, heading_slug_func=None, footnote_transition=True, words_per_minute=200, sub_delimiters=('{', '}'), linkify_fuzzy_links=True, dmath_allow_labels=True, dmath_allow_space=True, dmath_allow_digits=True, dmath_double_inline=False, update_mathjax=True, mathjax_classes='tex2jax_process|mathjax_process|math|output_area')
building [mo]: targets for 0 po files that are out of date
building [html]: targets for 3 source files that are out of date
updating environment: [new config] 3 added, 0 changed, 0 removed
reading sources... [100%] orphan                                               
looking for now-outdated files... none found
pickling environment... done
checking consistency... done
preparing documents... done
writing output... [100%] orphan                                                
generating indices... genindex done
writing additional pages... search done
copying images... [100%] example.jpg                                           
copying static files... done
copying extra files... done
dumping search index in English (code: en)... done
dumping object inventory... done
build succeeded.

The HTML pages are in tests/test_sphinx/sourcedirs/basic/_build/html.

problem I packaged pytest_param_files as RPM and ran all the tests (except those which rely on linkify). This time the test suite passed. But here's the thing, AFAIK it shouldn't be needed for this particular test to pass. It's never used in test_sphinx_builds.py and conftest.py. The problem in the pasted output doesn't seem connected to that small fixture at all. Yet when it is present and I let all the test run, something in the environment changes so that the output doesn't produce the additional language="" string. At this point of investigation I don't know what I ran into - whether my original setup was botched and failure could be expected or there's a bug in test setup that makes the environment fragile, or maybe even a problem in production code that's kind of shadowed by the tests. I don't really know how to proceed from here, except I'll add pytest_param_files to Fedora and hope for the best.

Reproduce the bug

In a Python3.11~b3 venv (works in Python 3.10 too), set up the test environment for MyST-Parser

$ python -m pip install --upgrade pip
# The problem occurs with the current master version too
$ pip install -e .[testing]
# Just make sure the correct versions are installed & not installed
$ pip install sphinx==5.0.2 docutils==0.18.1 pytest==7.1.2
$ pip uninstall pytest-param-files linkify-it-py
$ python -m pytest -k  'not test_extended_syntaxes' --ignore 'tests/test_renderers/test_fixtures_sphinx.py' --ignore 'tests/test_renderers/test_fixtures_docutils.py' --deselect 'tests/test_html/test_html_to_nodes.py::test_html_to_nodes' --deselect 'tests/test_html/test_parse_html.py::test_html_ast' --deselect 'tests/test_html/test_parse_html.py::test_html_round_trip' --deselect 'tests/test_renderers/test_error_reporting.py::test_basic' --deselect 'tests/test_renderers/test_include_directive.py::test_render' --deselect 'tests/test_renderers/test_include_directive.py::test_errors' --deselect 'tests/test_renderers/test_myst_config.py::test_cmdline' --deselect 'tests/test_renderers/test_parse_directives.py::test_parsing'


### List your environment

Fedora 35-37, MyST-Parser 0.18.0 (and current master), Python 3.10.5 & 3.11~beta3

Python 3.10.5 venv contents

$ pip freeze alabaster==0.7.12 attrs==21.4.0 Babel==2.10.3 beautifulsoup4==4.11.1 certifi==2022.6.15 charset-normalizer==2.0.12 coverage==6.4.1 docutils==0.18.1 idna==3.3 imagesize==1.3.0 iniconfig==1.1.1 Jinja2==3.1.2 markdown-it-py==2.1.0 MarkupSafe==2.1.1 mdit-py-plugins==0.3.0 mdurl==0.1.1 -e git+ssh://[email protected]/executablebooks/MyST-Parser.git@75ef9cb7b65c98d969724fc6c096e8d6209c5ea0#egg=myst_parser packaging==21.3 pluggy==1.0.0 py==1.11.0 Pygments==2.12.0 pyparsing==3.0.9 pytest==7.1.2 pytest-cov==3.0.0 pytest-datadir==1.3.1 pytest-regressions==2.3.1 pytz==2022.1 PyYAML==6.0 requests==2.28.0 snowballstemmer==2.2.0 soupsieve==2.3.2.post1 Sphinx==5.0.2 sphinx_pytest==0.0.4 sphinxcontrib-applehelp==1.0.2 sphinxcontrib-devhelp==1.0.2 sphinxcontrib-htmlhelp==2.0.0 sphinxcontrib-jsmath==1.0.1 sphinxcontrib-qthelp==1.0.3 sphinxcontrib-serializinghtml==1.1.5 toml==0.10.2 tomli==2.0.1 typing_extensions==4.2.0 urllib3==1.26.9

befeleme avatar Jun 28 '22 15:06 befeleme

Thanks for opening your first issue here! Engagement like this is essential for open source projects! :hugs:
If you haven't done so already, check out EBP's Code of Conduct. Also, please try to follow the issue template as it helps other community members to contribute more effectively.
If your issue is a feature request, others may react to it, to raise its prominence (see Feature Voting).
Welcome to the EBP community! :tada:

welcome[bot] avatar Jun 28 '22 15:06 welcome[bot]

Heya, FYI this appears to be nothing to do with pytest_param_files per se. I can replicate the same issue, by simply running: tox tests/test_sphinx/test_sphinx_builds.py::test_basic

So it appears that it is the order of testing that does it, i.e. something in a previous test is changing a global variable or something

chrisjsewell avatar Aug 31 '22 01:08 chrisjsewell

So it appears to be something to do with the docutils code role, if you run either of these tests, then things change:

tests/test_renderers/fixtures/docutil_roles.md:
  111  --------------------------------
  112  [code] (`docutils.parsers.rst.roles.code_role`):
  113  .
  114: {code}`a`
  115  .
  116  <document source="notset">
  117      <paragraph>

tests/test_renderers/fixtures/docutil_syntax_elements.md:
  287  
  288  Sphinx Role containing backtick:
  289  .
  290: {code}``a=1{`}``
  291  .
  292  <document source="notset">
  293      <paragraph>

chrisjsewell avatar Aug 31 '22 01:08 chrisjsewell

I'm experiencing a similar issue on Windows 10:

OS: Windows 10, 21H2 Python: 3.8.10, x64-bit MyST-Parser: 0.18.1

I followed the Contributing steps and installed linkify-py-it, but am getting 2 errors when I run pytest

======================================== short test summary info ======================================== 
FAILED tests/test_sphinx/test_sphinx_builds.py::test_gettext_html - AssertionError: FILES DIFFER:
FAILED tests/test_sphinx/test_sphinx_builds.py::test_fieldlist_extension - AssertionError: FILES DIFFER:
=============================== 2 failed, 999 passed, 14 skipped in 17.25s ==============================

Attached is an HTML report of the test run using pytest-html:

pytest_report.zip

adam-grant-hendry avatar Oct 18 '22 21:10 adam-grant-hendry

@chrisjsewell These fail consistently for python 3.7-3.10 and sphinx4 and 5, tested using tox. It's always:

  • test_fieldlist_extension.obtained.diff.html
  • test_gettext_html.obtained.diff.html

encoding_errors.zip

image

adam-grant-hendry avatar Oct 19 '22 00:10 adam-grant-hendry

@chrisjsewell These are also failing in your most recent CI, so there is definitely something awry here...

image

adam-grant-hendry avatar Oct 19 '22 00:10 adam-grant-hendry