Skip to content

Instantly share code, notes, and snippets.

@dhellmann
Created November 28, 2025 15:09
Show Gist options
  • Select an option

  • Save dhellmann/27d64eb733af50a2c1e460cc40164a11 to your computer and use it in GitHub Desktop.

Select an option

Save dhellmann/27d64eb733af50a2c1e460cc40164a11 to your computer and use it in GitHub Desktop.

Devise test case for GitHub issue

Exported on 11/28/2025 at 10:09:01 EST from Cursor (2.1.39)


User

I want to work on github issue sphinx-contrib/spelling#234 . Examine the existing tests in this repository and the info in the bug report and devise a test case to reproduce the problem.


Cursor

I'll help you investigate this issue and devise a test case to reproduce the problem. Let me start by examining the relevant code and existing tests.

Now let me examine how docutils.utils.get_source_line works and when it might return None:

# Avoid TypeError on nodes lacking a line number
# This happens for some node originating from docstrings

Let me search for more information about Sphinx 8.2 compatibility and check if there are any version-specific issues:

Let me try reading the pyproject file with a simpler approach:

Based on my analysis of the code and the bug report, I can now devise a test case to reproduce the issue. Let me create a plan for you.

Analysis

The issue occurs in sphinxcontrib/spelling/builder.py at line 231:

source, node_lineno = docutils.utils.get_source_line(node)
source = osutil.relpath(source)

The problem is that docutils.utils.get_source_line(node) can return None for the source when certain types of nodes are encountered (particularly with programmatically generated content or certain Sphinx 8.2 scenarios). The code then tries to call osutil.relpath(None), which causes the TypeError.

Interestingly, the code already handles the case where node_lineno is None (lines 238-240), but doesn't handle the case where source itself is None.

Test Case Strategy

To reproduce this issue, I need to create a test case that generates document nodes with None as their source. This can happen with:

  1. Programmatically generated content
  2. Certain Sphinx directives that create synthetic nodes
  3. Some autodoc scenarios (though the existing autodoc test passes, so it's context-specific)
  4. Nodes generated by extensions or custom directives
source, node_lineno = docutils.utils.get_source_line(node)
source = osutil.relpath(source)

User

Implement the plan as specified, it is attached for your reference. Do NOT edit the plan file itself.

To-do's from the plan have already been created. Do not create them again. Mark them as in_progress as you work, starting with the first one. Don't stop until you have completed all the to-dos.


Cursor

I'll implement the plan by creating a test case that reproduces issue #234. Let me start by examining the existing test patterns more closely and then create a test that triggers the source is None condition.

Now I'll create a test case that reproduces the issue. I'll create a test with a custom directive that generates nodes without proper source attribution, which should trigger the source is None condition:

#
# Copyright (c) 2010 Doug Hellmann.  All rights reserved.
#
"""Tests for SpellingBuilder"""

import contextlib
import io
import os
import sys
import textwrap

import pytest
from sphinx.application import Sphinx

from tests import helpers  # isort:skip


def _make_sphinx_project(tmpdir):
    srcdir = tmpdir.mkdir("src")
    outdir = tmpdir.mkdir("out")
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = [ 'sphinxcontrib.spelling' ]
    """,
    )
    return (srcdir, outdir)


@pytest.fixture
def sphinx_project(tmpdir):
    yield _make_sphinx_project(tmpdir)


@contextlib.contextmanager
def working_dir(targetdir):
    "Temporarily change the working directory of the process."
    before = os.getcwd()
    os.chdir(targetdir)
    try:
        yield os.getcwd()
    finally:
        os.chdir(before)


@contextlib.contextmanager
def import_path(new_path):
    "Temporarily change sys.path for imports."
    before = sys.path
    try:
        sys.path = new_path
        yield
    finally:
        sys.path = before


def add_file(thedir, filename, content):
    with open(thedir.join(filename), "w") as f:
        f.write(textwrap.dedent(content))


def get_sphinx_app(srcdir, outdir, docname, builder="spelling"):
    stdout = io.StringIO()
    stderr = io.StringIO()
    app = Sphinx(
        srcdir,
        srcdir,
        outdir,
        outdir,
        builder,
        status=stdout,
        warning=stderr,
        freshenv=True,
    )
    return (stdout, stderr, app)


def get_sphinx_output(srcdir, outdir, docname, builder="spelling"):
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, docname, builder)
    app.build()
    path = os.path.join(outdir, f"{docname}.spelling")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None
    return (stdout, stderr, output_text)


def test_setup(sphinx_project):
    srcdir, outdir = sphinx_project
    stdout = io.StringIO()
    stderr = io.StringIO()
    # If the spelling builder is not properly initialized,
    # trying to use it with the Sphinx app class will
    # generate an exception.
    Sphinx(
        str(srcdir),
        str(srcdir),
        str(outdir),
        str(outdir),
        "spelling",
        status=stdout,
        warning=stderr,
        freshenv=True,
    )


def test_title(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Speeling Checker documentation!
    ==========================================
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    assert "(Speeling)" in output_text


def test_body(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several mispelled words in this txt.
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    assert "(mispelled)" in output_text
    assert "(txt)" in output_text


def test_ignore_literals(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several misspelled words in this text.

    ::

        Literal blocks are ignoreed.

    Inline ``litterals`` are ignored, too.

    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # The 'contents.spelling' output file should not have been
    # created, because the errors are ignored.
    assert output_text is None


def test_several_word_lists(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_word_list_filename=['test_wordlist.txt','test_wordlist2.txt']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several mispelled words in tihs txt.
    """,
    )

    add_file(
        srcdir,
        "test_wordlist.txt",
        """
    txt
    """,
    )

    add_file(
        srcdir,
        "test_wordlist2.txt",
        """
    mispelled
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # Both of these should be fine now
    assert "(mispelled)" not in output_text
    assert "(txt)" not in output_text
    # But not this one
    assert "(tihs)" in output_text


def _wordlist_sphinx_project(tmpdir, conf_contents):
    srcdir, outdir = _make_sphinx_project(tmpdir)
    add_file(srcdir, "conf.py", conf_contents)
    add_file(
        srcdir,
        "test_wordlist.txt",
        """
    txt
    """,
    )
    add_file(
        srcdir,
        "test_wordlist2.txt",
        """
    mispelled
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    return (srcdir, outdir, stdout, stderr, app)


def test_word_list_default(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 1
    assert os.path.basename(results[0]) == "spelling_wordlist.txt"


def test_one_word_list_str(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename='test_wordlist.txt'
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 1
    assert os.path.basename(results[0]) == "test_wordlist.txt"


def test_multiple_word_list_str(tmpdir):
    # We don't expect anyone to set up their conf.py this way but it
    # simulates passing the configuration option from the command line
    # using -D.
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename='test_wordlist.txt,test_wordlist2.txt'
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 2
    assert os.path.basename(results[0]) == "test_wordlist.txt"
    assert os.path.basename(results[1]) == "test_wordlist2.txt"


def test_multiple_word_list_list(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename=['test_wordlist.txt', 'test_wordlist2.txt']
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 2
    assert os.path.basename(results[0]) == "test_wordlist.txt"
    assert os.path.basename(results[1]) == "test_wordlist2.txt"


def test_ignore_file(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_exclude_patterns=['con*']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Speeling Checker documentation!
    ==========================================
    """,
    )

    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # The 'contents.spelling' output file should not have been
    # created, because the file is ignored.
    assert output_text is None


@helpers.require_git_repo
def test_docstrings(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling', 'sphinx.ext.autodoc']
    """,
    )

    add_file(
        srcdir / "..",
        "the_source.py",
        '''
    #!/usr/bin/env python3

    def public_function(arg_name):
        """Does something useful.

        :param arg_name: Pass a vaule
        """
        return 1
    ''',
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. automodule:: the_source
       :members:

    """,
    )

    with working_dir(srcdir / ".."):
        with import_path(["."] + sys.path):
            stdout, stderr, output_text = get_sphinx_output(
                srcdir,
                outdir,
                "contents",
            )

    expected = "src/contents.rst:3: (vaule)  Pass a vaule\n"
    assert expected in output_text


def test_get_suggestions_to_show_all(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = True
    spelling_suggestion_limit = 0
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 3


def test_get_suggestions_to_show_limit(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = True
    spelling_suggestion_limit = 1
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 1


def test_get_suggestions_to_show_disabled(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = False
    spelling_suggestion_limit = 0
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 0


def test_captions(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. figure:: blah.gif

       Teh caption

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(Teh)" in output_text


def test_legacy_directive(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. spelling::

       teh

    teh is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_directive(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. spelling:word-list::

       teh

    teh is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role_multiple_words(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh is KO`

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role_output(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
        "text",
    )

    path = os.path.join(outdir, "contents.txt")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None

    assert output_text == "The Module\n**********\n\nteh is OK\n"


def test_domain_ignore(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`baddddd` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_ignore_multiple_words(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`baddddd` is OK here.

    But, baddddd is not OK here.
    Nor, here baddddd.

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(baddddd)" in output_text
    assert output_text.count("\n") == 2  # Only expect 2 errors, not 3.


def test_domain_ignore_output(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
        "text",
    )

    path = os.path.join(outdir, "contents.txt")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None

    assert output_text == "The Module\n**********\n\nteh is OK\n"


def test_only_directive(sphinx_project):
    # How to skip checking nested blocks of content
    # https://github.com/sphinx-contrib/spelling/issues/204
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. only:: html

       teh is ok

    whaat is not ok
    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(whaat)" in output_text
    assert "(teh)" not in output_text


def test_nodes_with_none_source(sphinx_project):
    # Reproduces https://github.com/sphinx-contrib/spelling/issues/234
    # Tests handling of nodes where get_source_line returns None for source
    srcdir, outdir = sphinx_project

    # Create a custom directive that generates nodes without proper source
    add_file(
        srcdir,
        "custom_directive.py",
        """
from docutils import nodes
from docutils.parsers.rst import Directive


class SyntheticContentDirective(Directive):
    '''Directive that creates nodes with None source'''
    has_content = True

    def run(self):
        # Create a paragraph node with text
        para = nodes.paragraph()
        text_node = nodes.Text('This has a mispeling')
        para += text_node
        # Explicitly clear the source to simulate the issue
        para.source = None
        para.line = None
        return [para]


def setup(app):
    app.add_directive('synthetic', SyntheticContentDirective)
    return {'version': '0.1'}
    """,
    )

    add_file(
        srcdir,
        "conf.py",
        """
import sys
sys.path.insert(0, '.')
extensions = ['sphinxcontrib.spelling', 'custom_directive']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Test Document
    =============

    .. synthetic::

       This content has a mispeling.
    """,
    )

    # This should raise TypeError when source is None
    # Without the fix, this will crash with:
    # TypeError: expected str, bytes or os.PathLike object, not NoneType
    with pytest.raises(TypeError) as exc_info:
        stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")

    assert "expected str, bytes or os.PathLike object, not NoneType" in str(
        exc_info.value
    )

Now let me check for any linting issues and then run the test to verify it reproduces the issue:

The linting warning is pre-existing and not related to my changes. Now let me run the test to verify it reproduces the issue:

I need to install the dependencies first. Let me check if there's a requirements file and install them:


User

use hatch to run the tests


Cursor

I'll use hatch to run the test:

Let me try running a different existing test to see if the issue is with the import or just my test:

Let me check the pyproject.toml to see how tests are configured:

Let me try running the test using the hatch test script:

Good progress! The test is running now. The issue is that the custom directive module can't be imported. Let me fix the test to properly set up the Python path:

#
# Copyright (c) 2010 Doug Hellmann.  All rights reserved.
#
"""Tests for SpellingBuilder"""

import contextlib
import io
import os
import sys
import textwrap

import pytest
from sphinx.application import Sphinx

from tests import helpers  # isort:skip


def _make_sphinx_project(tmpdir):
    srcdir = tmpdir.mkdir("src")
    outdir = tmpdir.mkdir("out")
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = [ 'sphinxcontrib.spelling' ]
    """,
    )
    return (srcdir, outdir)


@pytest.fixture
def sphinx_project(tmpdir):
    yield _make_sphinx_project(tmpdir)


@contextlib.contextmanager
def working_dir(targetdir):
    "Temporarily change the working directory of the process."
    before = os.getcwd()
    os.chdir(targetdir)
    try:
        yield os.getcwd()
    finally:
        os.chdir(before)


@contextlib.contextmanager
def import_path(new_path):
    "Temporarily change sys.path for imports."
    before = sys.path
    try:
        sys.path = new_path
        yield
    finally:
        sys.path = before


def add_file(thedir, filename, content):
    with open(thedir.join(filename), "w") as f:
        f.write(textwrap.dedent(content))


def get_sphinx_app(srcdir, outdir, docname, builder="spelling"):
    stdout = io.StringIO()
    stderr = io.StringIO()
    app = Sphinx(
        srcdir,
        srcdir,
        outdir,
        outdir,
        builder,
        status=stdout,
        warning=stderr,
        freshenv=True,
    )
    return (stdout, stderr, app)


def get_sphinx_output(srcdir, outdir, docname, builder="spelling"):
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, docname, builder)
    app.build()
    path = os.path.join(outdir, f"{docname}.spelling")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None
    return (stdout, stderr, output_text)


def test_setup(sphinx_project):
    srcdir, outdir = sphinx_project
    stdout = io.StringIO()
    stderr = io.StringIO()
    # If the spelling builder is not properly initialized,
    # trying to use it with the Sphinx app class will
    # generate an exception.
    Sphinx(
        str(srcdir),
        str(srcdir),
        str(outdir),
        str(outdir),
        "spelling",
        status=stdout,
        warning=stderr,
        freshenv=True,
    )


def test_title(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Speeling Checker documentation!
    ==========================================
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    assert "(Speeling)" in output_text


def test_body(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several mispelled words in this txt.
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    assert "(mispelled)" in output_text
    assert "(txt)" in output_text


def test_ignore_literals(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several misspelled words in this text.

    ::

        Literal blocks are ignoreed.

    Inline ``litterals`` are ignored, too.

    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # The 'contents.spelling' output file should not have been
    # created, because the errors are ignored.
    assert output_text is None


def test_several_word_lists(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_word_list_filename=['test_wordlist.txt','test_wordlist2.txt']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several mispelled words in tihs txt.
    """,
    )

    add_file(
        srcdir,
        "test_wordlist.txt",
        """
    txt
    """,
    )

    add_file(
        srcdir,
        "test_wordlist2.txt",
        """
    mispelled
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # Both of these should be fine now
    assert "(mispelled)" not in output_text
    assert "(txt)" not in output_text
    # But not this one
    assert "(tihs)" in output_text


def _wordlist_sphinx_project(tmpdir, conf_contents):
    srcdir, outdir = _make_sphinx_project(tmpdir)
    add_file(srcdir, "conf.py", conf_contents)
    add_file(
        srcdir,
        "test_wordlist.txt",
        """
    txt
    """,
    )
    add_file(
        srcdir,
        "test_wordlist2.txt",
        """
    mispelled
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    return (srcdir, outdir, stdout, stderr, app)


def test_word_list_default(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 1
    assert os.path.basename(results[0]) == "spelling_wordlist.txt"


def test_one_word_list_str(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename='test_wordlist.txt'
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 1
    assert os.path.basename(results[0]) == "test_wordlist.txt"


def test_multiple_word_list_str(tmpdir):
    # We don't expect anyone to set up their conf.py this way but it
    # simulates passing the configuration option from the command line
    # using -D.
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename='test_wordlist.txt,test_wordlist2.txt'
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 2
    assert os.path.basename(results[0]) == "test_wordlist.txt"
    assert os.path.basename(results[1]) == "test_wordlist2.txt"


def test_multiple_word_list_list(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename=['test_wordlist.txt', 'test_wordlist2.txt']
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 2
    assert os.path.basename(results[0]) == "test_wordlist.txt"
    assert os.path.basename(results[1]) == "test_wordlist2.txt"


def test_ignore_file(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_exclude_patterns=['con*']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Speeling Checker documentation!
    ==========================================
    """,
    )

    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # The 'contents.spelling' output file should not have been
    # created, because the file is ignored.
    assert output_text is None


@helpers.require_git_repo
def test_docstrings(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling', 'sphinx.ext.autodoc']
    """,
    )

    add_file(
        srcdir / "..",
        "the_source.py",
        '''
    #!/usr/bin/env python3

    def public_function(arg_name):
        """Does something useful.

        :param arg_name: Pass a vaule
        """
        return 1
    ''',
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. automodule:: the_source
       :members:

    """,
    )

    with working_dir(srcdir / ".."):
        with import_path(["."] + sys.path):
            stdout, stderr, output_text = get_sphinx_output(
                srcdir,
                outdir,
                "contents",
            )

    expected = "src/contents.rst:3: (vaule)  Pass a vaule\n"
    assert expected in output_text


def test_get_suggestions_to_show_all(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = True
    spelling_suggestion_limit = 0
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 3


def test_get_suggestions_to_show_limit(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = True
    spelling_suggestion_limit = 1
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 1


def test_get_suggestions_to_show_disabled(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = False
    spelling_suggestion_limit = 0
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 0


def test_captions(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. figure:: blah.gif

       Teh caption

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(Teh)" in output_text


def test_legacy_directive(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. spelling::

       teh

    teh is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_directive(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. spelling:word-list::

       teh

    teh is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role_multiple_words(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh is KO`

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role_output(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
        "text",
    )

    path = os.path.join(outdir, "contents.txt")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None

    assert output_text == "The Module\n**********\n\nteh is OK\n"


def test_domain_ignore(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`baddddd` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_ignore_multiple_words(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`baddddd` is OK here.

    But, baddddd is not OK here.
    Nor, here baddddd.

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(baddddd)" in output_text
    assert output_text.count("\n") == 2  # Only expect 2 errors, not 3.


def test_domain_ignore_output(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
        "text",
    )

    path = os.path.join(outdir, "contents.txt")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None

    assert output_text == "The Module\n**********\n\nteh is OK\n"


def test_only_directive(sphinx_project):
    # How to skip checking nested blocks of content
    # https://github.com/sphinx-contrib/spelling/issues/204
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. only:: html

       teh is ok

    whaat is not ok
    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(whaat)" in output_text
    assert "(teh)" not in output_text


def test_nodes_with_none_source(sphinx_project):
    # Reproduces https://github.com/sphinx-contrib/spelling/issues/234
    # Tests handling of nodes where get_source_line returns None for source
    srcdir, outdir = sphinx_project

    # Create a custom directive that generates nodes without proper source
    add_file(
        srcdir,
        "custom_directive.py",
        """
from docutils import nodes
from docutils.parsers.rst import Directive


class SyntheticContentDirective(Directive):
    '''Directive that creates nodes with None source'''
    has_content = True

    def run(self):
        # Create a paragraph node with text
        para = nodes.paragraph()
        text_node = nodes.Text('This has a mispeling')
        para += text_node
        # Explicitly clear the source to simulate the issue
        para.source = None
        para.line = None
        return [para]


def setup(app):
    app.add_directive('synthetic', SyntheticContentDirective)
    return {'version': '0.1'}
    """,
    )

    add_file(
        srcdir,
        "conf.py",
        f"""
import sys
sys.path.insert(0, r'{srcdir}')
extensions = ['sphinxcontrib.spelling', 'custom_directive']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Test Document
    =============

    .. synthetic::

       This content has a mispeling.
    """,
    )

    # This should raise TypeError when source is None
    # Without the fix, this will crash with:
    # TypeError: expected str, bytes or os.PathLike object, not NoneType
    with pytest.raises(TypeError) as exc_info:
        stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")

    assert "expected str, bytes or os.PathLike object, not NoneType" in str(
        exc_info.value
    )

Now let's run the test again:

Interesting! The test didn't raise a TypeError, which means my approach isn't correctly reproducing the issue. Let me modify the test to see what's actually happening and examine the output:

#
# Copyright (c) 2010 Doug Hellmann.  All rights reserved.
#
"""Tests for SpellingBuilder"""

import contextlib
import io
import os
import sys
import textwrap

import pytest
from sphinx.application import Sphinx

from tests import helpers  # isort:skip


def _make_sphinx_project(tmpdir):
    srcdir = tmpdir.mkdir("src")
    outdir = tmpdir.mkdir("out")
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = [ 'sphinxcontrib.spelling' ]
    """,
    )
    return (srcdir, outdir)


@pytest.fixture
def sphinx_project(tmpdir):
    yield _make_sphinx_project(tmpdir)


@contextlib.contextmanager
def working_dir(targetdir):
    "Temporarily change the working directory of the process."
    before = os.getcwd()
    os.chdir(targetdir)
    try:
        yield os.getcwd()
    finally:
        os.chdir(before)


@contextlib.contextmanager
def import_path(new_path):
    "Temporarily change sys.path for imports."
    before = sys.path
    try:
        sys.path = new_path
        yield
    finally:
        sys.path = before


def add_file(thedir, filename, content):
    with open(thedir.join(filename), "w") as f:
        f.write(textwrap.dedent(content))


def get_sphinx_app(srcdir, outdir, docname, builder="spelling"):
    stdout = io.StringIO()
    stderr = io.StringIO()
    app = Sphinx(
        srcdir,
        srcdir,
        outdir,
        outdir,
        builder,
        status=stdout,
        warning=stderr,
        freshenv=True,
    )
    return (stdout, stderr, app)


def get_sphinx_output(srcdir, outdir, docname, builder="spelling"):
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, docname, builder)
    app.build()
    path = os.path.join(outdir, f"{docname}.spelling")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None
    return (stdout, stderr, output_text)


def test_setup(sphinx_project):
    srcdir, outdir = sphinx_project
    stdout = io.StringIO()
    stderr = io.StringIO()
    # If the spelling builder is not properly initialized,
    # trying to use it with the Sphinx app class will
    # generate an exception.
    Sphinx(
        str(srcdir),
        str(srcdir),
        str(outdir),
        str(outdir),
        "spelling",
        status=stdout,
        warning=stderr,
        freshenv=True,
    )


def test_title(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Speeling Checker documentation!
    ==========================================
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    assert "(Speeling)" in output_text


def test_body(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several mispelled words in this txt.
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    assert "(mispelled)" in output_text
    assert "(txt)" in output_text


def test_ignore_literals(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several misspelled words in this text.

    ::

        Literal blocks are ignoreed.

    Inline ``litterals`` are ignored, too.

    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # The 'contents.spelling' output file should not have been
    # created, because the errors are ignored.
    assert output_text is None


def test_several_word_lists(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_word_list_filename=['test_wordlist.txt','test_wordlist2.txt']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several mispelled words in tihs txt.
    """,
    )

    add_file(
        srcdir,
        "test_wordlist.txt",
        """
    txt
    """,
    )

    add_file(
        srcdir,
        "test_wordlist2.txt",
        """
    mispelled
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # Both of these should be fine now
    assert "(mispelled)" not in output_text
    assert "(txt)" not in output_text
    # But not this one
    assert "(tihs)" in output_text


def _wordlist_sphinx_project(tmpdir, conf_contents):
    srcdir, outdir = _make_sphinx_project(tmpdir)
    add_file(srcdir, "conf.py", conf_contents)
    add_file(
        srcdir,
        "test_wordlist.txt",
        """
    txt
    """,
    )
    add_file(
        srcdir,
        "test_wordlist2.txt",
        """
    mispelled
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    return (srcdir, outdir, stdout, stderr, app)


def test_word_list_default(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 1
    assert os.path.basename(results[0]) == "spelling_wordlist.txt"


def test_one_word_list_str(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename='test_wordlist.txt'
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 1
    assert os.path.basename(results[0]) == "test_wordlist.txt"


def test_multiple_word_list_str(tmpdir):
    # We don't expect anyone to set up their conf.py this way but it
    # simulates passing the configuration option from the command line
    # using -D.
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename='test_wordlist.txt,test_wordlist2.txt'
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 2
    assert os.path.basename(results[0]) == "test_wordlist.txt"
    assert os.path.basename(results[1]) == "test_wordlist2.txt"


def test_multiple_word_list_list(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename=['test_wordlist.txt', 'test_wordlist2.txt']
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 2
    assert os.path.basename(results[0]) == "test_wordlist.txt"
    assert os.path.basename(results[1]) == "test_wordlist2.txt"


def test_ignore_file(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_exclude_patterns=['con*']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Speeling Checker documentation!
    ==========================================
    """,
    )

    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # The 'contents.spelling' output file should not have been
    # created, because the file is ignored.
    assert output_text is None


@helpers.require_git_repo
def test_docstrings(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling', 'sphinx.ext.autodoc']
    """,
    )

    add_file(
        srcdir / "..",
        "the_source.py",
        '''
    #!/usr/bin/env python3

    def public_function(arg_name):
        """Does something useful.

        :param arg_name: Pass a vaule
        """
        return 1
    ''',
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. automodule:: the_source
       :members:

    """,
    )

    with working_dir(srcdir / ".."):
        with import_path(["."] + sys.path):
            stdout, stderr, output_text = get_sphinx_output(
                srcdir,
                outdir,
                "contents",
            )

    expected = "src/contents.rst:3: (vaule)  Pass a vaule\n"
    assert expected in output_text


def test_get_suggestions_to_show_all(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = True
    spelling_suggestion_limit = 0
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 3


def test_get_suggestions_to_show_limit(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = True
    spelling_suggestion_limit = 1
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 1


def test_get_suggestions_to_show_disabled(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = False
    spelling_suggestion_limit = 0
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 0


def test_captions(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. figure:: blah.gif

       Teh caption

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(Teh)" in output_text


def test_legacy_directive(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. spelling::

       teh

    teh is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_directive(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. spelling:word-list::

       teh

    teh is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role_multiple_words(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh is KO`

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role_output(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
        "text",
    )

    path = os.path.join(outdir, "contents.txt")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None

    assert output_text == "The Module\n**********\n\nteh is OK\n"


def test_domain_ignore(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`baddddd` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_ignore_multiple_words(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`baddddd` is OK here.

    But, baddddd is not OK here.
    Nor, here baddddd.

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(baddddd)" in output_text
    assert output_text.count("\n") == 2  # Only expect 2 errors, not 3.


def test_domain_ignore_output(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
        "text",
    )

    path = os.path.join(outdir, "contents.txt")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None

    assert output_text == "The Module\n**********\n\nteh is OK\n"


def test_only_directive(sphinx_project):
    # How to skip checking nested blocks of content
    # https://github.com/sphinx-contrib/spelling/issues/204
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. only:: html

       teh is ok

    whaat is not ok
    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(whaat)" in output_text
    assert "(teh)" not in output_text


def test_nodes_with_none_source(sphinx_project):
    # Reproduces https://github.com/sphinx-contrib/spelling/issues/234
    # Tests handling of nodes where get_source_line returns None for source
    srcdir, outdir = sphinx_project

    # Create a custom directive that generates nodes without proper source
    add_file(
        srcdir,
        "custom_directive.py",
        """
from docutils import nodes
from docutils.parsers.rst import Directive


class SyntheticContentDirective(Directive):
    '''Directive that creates nodes with None source'''
    has_content = True

    def run(self):
        # Create a paragraph node with text
        para = nodes.paragraph()
        text_node = nodes.Text('This has a mispeling')
        para += text_node
        # Explicitly clear the source to simulate the issue
        para.source = None
        para.line = None
        return [para]


def setup(app):
    app.add_directive('synthetic', SyntheticContentDirective)
    return {'version': '0.1'}
    """,
    )

    add_file(
        srcdir,
        "conf.py",
        f"""
import sys
sys.path.insert(0, r'{srcdir}')
extensions = ['sphinxcontrib.spelling', 'custom_directive']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Test Document
    =============

    .. synthetic::

       This content has a mispeling.
    """,
    )

    # This should raise TypeError when source is None
    # Without the fix, this will crash with:
    # TypeError: expected str, bytes or os.PathLike object, not NoneType
    try:
        stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
        # If we get here without error, let's check what happened
        print(f"stdout: {stdout.getvalue()}")
        print(f"stderr: {stderr.getvalue()}")
        print(f"output_text: {output_text}")
        pytest.fail("Expected TypeError but none was raised")
    except TypeError as e:
        # This is what we expect
        assert "expected str, bytes or os.PathLike object, not NoneType" in str(e)

I see the issue - the nodes still have a source (contents.rst:7). I need to create nodes where docutils.utils.get_source_line(node) actually returns None. Let me try a different approach by creating text nodes more directly without proper document context:

#
# Copyright (c) 2010 Doug Hellmann.  All rights reserved.
#
"""Tests for SpellingBuilder"""

import contextlib
import io
import os
import sys
import textwrap

import pytest
from sphinx.application import Sphinx

from tests import helpers  # isort:skip


def _make_sphinx_project(tmpdir):
    srcdir = tmpdir.mkdir("src")
    outdir = tmpdir.mkdir("out")
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = [ 'sphinxcontrib.spelling' ]
    """,
    )
    return (srcdir, outdir)


@pytest.fixture
def sphinx_project(tmpdir):
    yield _make_sphinx_project(tmpdir)


@contextlib.contextmanager
def working_dir(targetdir):
    "Temporarily change the working directory of the process."
    before = os.getcwd()
    os.chdir(targetdir)
    try:
        yield os.getcwd()
    finally:
        os.chdir(before)


@contextlib.contextmanager
def import_path(new_path):
    "Temporarily change sys.path for imports."
    before = sys.path
    try:
        sys.path = new_path
        yield
    finally:
        sys.path = before


def add_file(thedir, filename, content):
    with open(thedir.join(filename), "w") as f:
        f.write(textwrap.dedent(content))


def get_sphinx_app(srcdir, outdir, docname, builder="spelling"):
    stdout = io.StringIO()
    stderr = io.StringIO()
    app = Sphinx(
        srcdir,
        srcdir,
        outdir,
        outdir,
        builder,
        status=stdout,
        warning=stderr,
        freshenv=True,
    )
    return (stdout, stderr, app)


def get_sphinx_output(srcdir, outdir, docname, builder="spelling"):
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, docname, builder)
    app.build()
    path = os.path.join(outdir, f"{docname}.spelling")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None
    return (stdout, stderr, output_text)


def test_setup(sphinx_project):
    srcdir, outdir = sphinx_project
    stdout = io.StringIO()
    stderr = io.StringIO()
    # If the spelling builder is not properly initialized,
    # trying to use it with the Sphinx app class will
    # generate an exception.
    Sphinx(
        str(srcdir),
        str(srcdir),
        str(outdir),
        str(outdir),
        "spelling",
        status=stdout,
        warning=stderr,
        freshenv=True,
    )


def test_title(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Speeling Checker documentation!
    ==========================================
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    assert "(Speeling)" in output_text


def test_body(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several mispelled words in this txt.
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    assert "(mispelled)" in output_text
    assert "(txt)" in output_text


def test_ignore_literals(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several misspelled words in this text.

    ::

        Literal blocks are ignoreed.

    Inline ``litterals`` are ignored, too.

    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # The 'contents.spelling' output file should not have been
    # created, because the errors are ignored.
    assert output_text is None


def test_several_word_lists(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_word_list_filename=['test_wordlist.txt','test_wordlist2.txt']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several mispelled words in tihs txt.
    """,
    )

    add_file(
        srcdir,
        "test_wordlist.txt",
        """
    txt
    """,
    )

    add_file(
        srcdir,
        "test_wordlist2.txt",
        """
    mispelled
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # Both of these should be fine now
    assert "(mispelled)" not in output_text
    assert "(txt)" not in output_text
    # But not this one
    assert "(tihs)" in output_text


def _wordlist_sphinx_project(tmpdir, conf_contents):
    srcdir, outdir = _make_sphinx_project(tmpdir)
    add_file(srcdir, "conf.py", conf_contents)
    add_file(
        srcdir,
        "test_wordlist.txt",
        """
    txt
    """,
    )
    add_file(
        srcdir,
        "test_wordlist2.txt",
        """
    mispelled
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    return (srcdir, outdir, stdout, stderr, app)


def test_word_list_default(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 1
    assert os.path.basename(results[0]) == "spelling_wordlist.txt"


def test_one_word_list_str(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename='test_wordlist.txt'
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 1
    assert os.path.basename(results[0]) == "test_wordlist.txt"


def test_multiple_word_list_str(tmpdir):
    # We don't expect anyone to set up their conf.py this way but it
    # simulates passing the configuration option from the command line
    # using -D.
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename='test_wordlist.txt,test_wordlist2.txt'
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 2
    assert os.path.basename(results[0]) == "test_wordlist.txt"
    assert os.path.basename(results[1]) == "test_wordlist2.txt"


def test_multiple_word_list_list(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename=['test_wordlist.txt', 'test_wordlist2.txt']
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 2
    assert os.path.basename(results[0]) == "test_wordlist.txt"
    assert os.path.basename(results[1]) == "test_wordlist2.txt"


def test_ignore_file(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_exclude_patterns=['con*']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Speeling Checker documentation!
    ==========================================
    """,
    )

    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # The 'contents.spelling' output file should not have been
    # created, because the file is ignored.
    assert output_text is None


@helpers.require_git_repo
def test_docstrings(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling', 'sphinx.ext.autodoc']
    """,
    )

    add_file(
        srcdir / "..",
        "the_source.py",
        '''
    #!/usr/bin/env python3

    def public_function(arg_name):
        """Does something useful.

        :param arg_name: Pass a vaule
        """
        return 1
    ''',
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. automodule:: the_source
       :members:

    """,
    )

    with working_dir(srcdir / ".."):
        with import_path(["."] + sys.path):
            stdout, stderr, output_text = get_sphinx_output(
                srcdir,
                outdir,
                "contents",
            )

    expected = "src/contents.rst:3: (vaule)  Pass a vaule\n"
    assert expected in output_text


def test_get_suggestions_to_show_all(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = True
    spelling_suggestion_limit = 0
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 3


def test_get_suggestions_to_show_limit(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = True
    spelling_suggestion_limit = 1
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 1


def test_get_suggestions_to_show_disabled(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = False
    spelling_suggestion_limit = 0
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 0


def test_captions(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. figure:: blah.gif

       Teh caption

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(Teh)" in output_text


def test_legacy_directive(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. spelling::

       teh

    teh is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_directive(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. spelling:word-list::

       teh

    teh is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role_multiple_words(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh is KO`

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role_output(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
        "text",
    )

    path = os.path.join(outdir, "contents.txt")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None

    assert output_text == "The Module\n**********\n\nteh is OK\n"


def test_domain_ignore(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`baddddd` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_ignore_multiple_words(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`baddddd` is OK here.

    But, baddddd is not OK here.
    Nor, here baddddd.

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(baddddd)" in output_text
    assert output_text.count("\n") == 2  # Only expect 2 errors, not 3.


def test_domain_ignore_output(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
        "text",
    )

    path = os.path.join(outdir, "contents.txt")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None

    assert output_text == "The Module\n**********\n\nteh is OK\n"


def test_only_directive(sphinx_project):
    # How to skip checking nested blocks of content
    # https://github.com/sphinx-contrib/spelling/issues/204
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. only:: html

       teh is ok

    whaat is not ok
    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(whaat)" in output_text
    assert "(teh)" not in output_text


def test_nodes_with_none_source(sphinx_project):
    # Reproduces https://github.com/sphinx-contrib/spelling/issues/234
    # Tests handling of nodes where get_source_line returns None for source
    srcdir, outdir = sphinx_project

    # Create a custom directive that generates nodes without proper source
    # The key is to create text nodes that don't inherit source from parent
    add_file(
        srcdir,
        "custom_directive.py",
        """
from docutils import nodes
from docutils.parsers.rst import Directive


class SyntheticContentDirective(Directive):
    '''Directive that creates nodes with None source'''
    has_content = False
    required_arguments = 0
    optional_arguments = 0

    def run(self):
        # Create nodes completely detached from the document tree initially
        # so they don't inherit source information
        para = nodes.paragraph('', '')
        # Create a text node without source information
        text_node = nodes.Text('This has a mispeling')
        para += text_node

        # Ensure no source/line info by creating a fresh paragraph
        # that doesn't track back to the RST file
        para.source = None
        para.line = None
        text_node.source = None
        text_node.line = None

        # Also try to prevent docutils from finding parent source
        para.document = None

        return [para]


def setup(app):
    app.add_directive('synthetic', SyntheticContentDirective)
    return {'version': '0.1'}
    """,
    )

    add_file(
        srcdir,
        "conf.py",
        f"""
import sys
sys.path.insert(0, r'{srcdir}')
extensions = ['sphinxcontrib.spelling', 'custom_directive']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Test Document
    =============

    .. synthetic::
    """,
    )

    # This should raise TypeError when source is None
    # Without the fix, this will crash with:
    # TypeError: expected str, bytes or os.PathLike object, not NoneType
    try:
        stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
        # If we get here without error, let's check what happened
        print(f"stdout: {stdout.getvalue()}")
        print(f"stderr: {stderr.getvalue()}")
        print(f"output_text: {output_text}")
        pytest.fail("Expected TypeError but none was raised")
    except TypeError as e:
        # This is what we expect
        assert "expected str, bytes or os.PathLike object, not NoneType" in str(e)
source, node_lineno = docutils.utils.get_source_line(node)
source = osutil.relpath(source)

Let me try a different approach using a transform to modify the doctree after parsing and clear source information on text nodes:

#
# Copyright (c) 2010 Doug Hellmann.  All rights reserved.
#
"""Tests for SpellingBuilder"""

import contextlib
import io
import os
import sys
import textwrap

import pytest
from sphinx.application import Sphinx

from tests import helpers  # isort:skip


def _make_sphinx_project(tmpdir):
    srcdir = tmpdir.mkdir("src")
    outdir = tmpdir.mkdir("out")
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = [ 'sphinxcontrib.spelling' ]
    """,
    )
    return (srcdir, outdir)


@pytest.fixture
def sphinx_project(tmpdir):
    yield _make_sphinx_project(tmpdir)


@contextlib.contextmanager
def working_dir(targetdir):
    "Temporarily change the working directory of the process."
    before = os.getcwd()
    os.chdir(targetdir)
    try:
        yield os.getcwd()
    finally:
        os.chdir(before)


@contextlib.contextmanager
def import_path(new_path):
    "Temporarily change sys.path for imports."
    before = sys.path
    try:
        sys.path = new_path
        yield
    finally:
        sys.path = before


def add_file(thedir, filename, content):
    with open(thedir.join(filename), "w") as f:
        f.write(textwrap.dedent(content))


def get_sphinx_app(srcdir, outdir, docname, builder="spelling"):
    stdout = io.StringIO()
    stderr = io.StringIO()
    app = Sphinx(
        srcdir,
        srcdir,
        outdir,
        outdir,
        builder,
        status=stdout,
        warning=stderr,
        freshenv=True,
    )
    return (stdout, stderr, app)


def get_sphinx_output(srcdir, outdir, docname, builder="spelling"):
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, docname, builder)
    app.build()
    path = os.path.join(outdir, f"{docname}.spelling")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None
    return (stdout, stderr, output_text)


def test_setup(sphinx_project):
    srcdir, outdir = sphinx_project
    stdout = io.StringIO()
    stderr = io.StringIO()
    # If the spelling builder is not properly initialized,
    # trying to use it with the Sphinx app class will
    # generate an exception.
    Sphinx(
        str(srcdir),
        str(srcdir),
        str(outdir),
        str(outdir),
        "spelling",
        status=stdout,
        warning=stderr,
        freshenv=True,
    )


def test_title(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Speeling Checker documentation!
    ==========================================
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    assert "(Speeling)" in output_text


def test_body(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several mispelled words in this txt.
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    assert "(mispelled)" in output_text
    assert "(txt)" in output_text


def test_ignore_literals(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several misspelled words in this text.

    ::

        Literal blocks are ignoreed.

    Inline ``litterals`` are ignored, too.

    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # The 'contents.spelling' output file should not have been
    # created, because the errors are ignored.
    assert output_text is None


def test_several_word_lists(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_word_list_filename=['test_wordlist.txt','test_wordlist2.txt']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several mispelled words in tihs txt.
    """,
    )

    add_file(
        srcdir,
        "test_wordlist.txt",
        """
    txt
    """,
    )

    add_file(
        srcdir,
        "test_wordlist2.txt",
        """
    mispelled
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # Both of these should be fine now
    assert "(mispelled)" not in output_text
    assert "(txt)" not in output_text
    # But not this one
    assert "(tihs)" in output_text


def _wordlist_sphinx_project(tmpdir, conf_contents):
    srcdir, outdir = _make_sphinx_project(tmpdir)
    add_file(srcdir, "conf.py", conf_contents)
    add_file(
        srcdir,
        "test_wordlist.txt",
        """
    txt
    """,
    )
    add_file(
        srcdir,
        "test_wordlist2.txt",
        """
    mispelled
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    return (srcdir, outdir, stdout, stderr, app)


def test_word_list_default(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 1
    assert os.path.basename(results[0]) == "spelling_wordlist.txt"


def test_one_word_list_str(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename='test_wordlist.txt'
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 1
    assert os.path.basename(results[0]) == "test_wordlist.txt"


def test_multiple_word_list_str(tmpdir):
    # We don't expect anyone to set up their conf.py this way but it
    # simulates passing the configuration option from the command line
    # using -D.
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename='test_wordlist.txt,test_wordlist2.txt'
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 2
    assert os.path.basename(results[0]) == "test_wordlist.txt"
    assert os.path.basename(results[1]) == "test_wordlist2.txt"


def test_multiple_word_list_list(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename=['test_wordlist.txt', 'test_wordlist2.txt']
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 2
    assert os.path.basename(results[0]) == "test_wordlist.txt"
    assert os.path.basename(results[1]) == "test_wordlist2.txt"


def test_ignore_file(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_exclude_patterns=['con*']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Speeling Checker documentation!
    ==========================================
    """,
    )

    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # The 'contents.spelling' output file should not have been
    # created, because the file is ignored.
    assert output_text is None


@helpers.require_git_repo
def test_docstrings(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling', 'sphinx.ext.autodoc']
    """,
    )

    add_file(
        srcdir / "..",
        "the_source.py",
        '''
    #!/usr/bin/env python3

    def public_function(arg_name):
        """Does something useful.

        :param arg_name: Pass a vaule
        """
        return 1
    ''',
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. automodule:: the_source
       :members:

    """,
    )

    with working_dir(srcdir / ".."):
        with import_path(["."] + sys.path):
            stdout, stderr, output_text = get_sphinx_output(
                srcdir,
                outdir,
                "contents",
            )

    expected = "src/contents.rst:3: (vaule)  Pass a vaule\n"
    assert expected in output_text


def test_get_suggestions_to_show_all(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = True
    spelling_suggestion_limit = 0
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 3


def test_get_suggestions_to_show_limit(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = True
    spelling_suggestion_limit = 1
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 1


def test_get_suggestions_to_show_disabled(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = False
    spelling_suggestion_limit = 0
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 0


def test_captions(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. figure:: blah.gif

       Teh caption

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(Teh)" in output_text


def test_legacy_directive(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. spelling::

       teh

    teh is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_directive(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. spelling:word-list::

       teh

    teh is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role_multiple_words(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh is KO`

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role_output(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
        "text",
    )

    path = os.path.join(outdir, "contents.txt")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None

    assert output_text == "The Module\n**********\n\nteh is OK\n"


def test_domain_ignore(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`baddddd` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_ignore_multiple_words(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`baddddd` is OK here.

    But, baddddd is not OK here.
    Nor, here baddddd.

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(baddddd)" in output_text
    assert output_text.count("\n") == 2  # Only expect 2 errors, not 3.


def test_domain_ignore_output(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
        "text",
    )

    path = os.path.join(outdir, "contents.txt")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None

    assert output_text == "The Module\n**********\n\nteh is OK\n"


def test_only_directive(sphinx_project):
    # How to skip checking nested blocks of content
    # https://github.com/sphinx-contrib/spelling/issues/204
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. only:: html

       teh is ok

    whaat is not ok
    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(whaat)" in output_text
    assert "(teh)" not in output_text


def test_nodes_with_none_source(sphinx_project):
    # Reproduces https://github.com/sphinx-contrib/spelling/issues/234
    # Tests handling of nodes where get_source_line returns None for source
    srcdir, outdir = sphinx_project

    # Use a transform to clear source information from nodes after parsing
    # This simulates the condition where get_source_line returns (None, line_num)
    add_file(
        srcdir,
        "clear_source_extension.py",
        """
from docutils import nodes
from sphinx.transforms import SphinxTransform


class ClearSourceTransform(SphinxTransform):
    '''Transform that removes source information from text nodes'''
    default_priority = 1  # Run early, before spelling checker

    def apply(self):
        # Find all text nodes and clear their source
        for node in self.document.findall(nodes.Text):
            # Get the parent node and clear its source
            parent = node.parent
            if parent and hasattr(parent, 'source'):
                # This simulates the condition in issue #234
                parent.source = None
                parent.line = None


def setup(app):
    app.add_transform(ClearSourceTransform)
    return {'version': '0.1'}
    """,
    )

    add_file(
        srcdir,
        "conf.py",
        f"""
import sys
sys.path.insert(0, r'{srcdir}')
extensions = ['sphinxcontrib.spelling', 'clear_source_extension']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Test Document
    =============

    This text has a mispeling that should be caught.
    """,
    )

    # This should raise TypeError when source is None
    # Without the fix, this will crash with:
    # TypeError: expected str, bytes or os.PathLike object, not NoneType
    try:
        stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
        # If we get here without error, let's check what happened
        print(f"stdout: {stdout.getvalue()}")
        print(f"stderr: {stderr.getvalue()}")
        print(f"output_text: {output_text}")
        pytest.fail("Expected TypeError but none was raised")
    except TypeError as e:
        # This is what we expect
        assert "expected str, bytes or os.PathLike object, not NoneType" in str(e)

Let me try a more direct approach - creating a minimal test case that directly calls the problematic code with a node that has None source:

#
# Copyright (c) 2010 Doug Hellmann.  All rights reserved.
#
"""Tests for SpellingBuilder"""

import contextlib
import io
import os
import sys
import textwrap

import pytest
from sphinx.application import Sphinx

from tests import helpers  # isort:skip


def _make_sphinx_project(tmpdir):
    srcdir = tmpdir.mkdir("src")
    outdir = tmpdir.mkdir("out")
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = [ 'sphinxcontrib.spelling' ]
    """,
    )
    return (srcdir, outdir)


@pytest.fixture
def sphinx_project(tmpdir):
    yield _make_sphinx_project(tmpdir)


@contextlib.contextmanager
def working_dir(targetdir):
    "Temporarily change the working directory of the process."
    before = os.getcwd()
    os.chdir(targetdir)
    try:
        yield os.getcwd()
    finally:
        os.chdir(before)


@contextlib.contextmanager
def import_path(new_path):
    "Temporarily change sys.path for imports."
    before = sys.path
    try:
        sys.path = new_path
        yield
    finally:
        sys.path = before


def add_file(thedir, filename, content):
    with open(thedir.join(filename), "w") as f:
        f.write(textwrap.dedent(content))


def get_sphinx_app(srcdir, outdir, docname, builder="spelling"):
    stdout = io.StringIO()
    stderr = io.StringIO()
    app = Sphinx(
        srcdir,
        srcdir,
        outdir,
        outdir,
        builder,
        status=stdout,
        warning=stderr,
        freshenv=True,
    )
    return (stdout, stderr, app)


def get_sphinx_output(srcdir, outdir, docname, builder="spelling"):
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, docname, builder)
    app.build()
    path = os.path.join(outdir, f"{docname}.spelling")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None
    return (stdout, stderr, output_text)


def test_setup(sphinx_project):
    srcdir, outdir = sphinx_project
    stdout = io.StringIO()
    stderr = io.StringIO()
    # If the spelling builder is not properly initialized,
    # trying to use it with the Sphinx app class will
    # generate an exception.
    Sphinx(
        str(srcdir),
        str(srcdir),
        str(outdir),
        str(outdir),
        "spelling",
        status=stdout,
        warning=stderr,
        freshenv=True,
    )


def test_title(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Speeling Checker documentation!
    ==========================================
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    assert "(Speeling)" in output_text


def test_body(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several mispelled words in this txt.
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    assert "(mispelled)" in output_text
    assert "(txt)" in output_text


def test_ignore_literals(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several misspelled words in this text.

    ::

        Literal blocks are ignoreed.

    Inline ``litterals`` are ignored, too.

    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # The 'contents.spelling' output file should not have been
    # created, because the errors are ignored.
    assert output_text is None


def test_several_word_lists(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_word_list_filename=['test_wordlist.txt','test_wordlist2.txt']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several mispelled words in tihs txt.
    """,
    )

    add_file(
        srcdir,
        "test_wordlist.txt",
        """
    txt
    """,
    )

    add_file(
        srcdir,
        "test_wordlist2.txt",
        """
    mispelled
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # Both of these should be fine now
    assert "(mispelled)" not in output_text
    assert "(txt)" not in output_text
    # But not this one
    assert "(tihs)" in output_text


def _wordlist_sphinx_project(tmpdir, conf_contents):
    srcdir, outdir = _make_sphinx_project(tmpdir)
    add_file(srcdir, "conf.py", conf_contents)
    add_file(
        srcdir,
        "test_wordlist.txt",
        """
    txt
    """,
    )
    add_file(
        srcdir,
        "test_wordlist2.txt",
        """
    mispelled
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    return (srcdir, outdir, stdout, stderr, app)


def test_word_list_default(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 1
    assert os.path.basename(results[0]) == "spelling_wordlist.txt"


def test_one_word_list_str(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename='test_wordlist.txt'
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 1
    assert os.path.basename(results[0]) == "test_wordlist.txt"


def test_multiple_word_list_str(tmpdir):
    # We don't expect anyone to set up their conf.py this way but it
    # simulates passing the configuration option from the command line
    # using -D.
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename='test_wordlist.txt,test_wordlist2.txt'
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 2
    assert os.path.basename(results[0]) == "test_wordlist.txt"
    assert os.path.basename(results[1]) == "test_wordlist2.txt"


def test_multiple_word_list_list(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename=['test_wordlist.txt', 'test_wordlist2.txt']
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 2
    assert os.path.basename(results[0]) == "test_wordlist.txt"
    assert os.path.basename(results[1]) == "test_wordlist2.txt"


def test_ignore_file(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_exclude_patterns=['con*']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Speeling Checker documentation!
    ==========================================
    """,
    )

    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # The 'contents.spelling' output file should not have been
    # created, because the file is ignored.
    assert output_text is None


@helpers.require_git_repo
def test_docstrings(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling', 'sphinx.ext.autodoc']
    """,
    )

    add_file(
        srcdir / "..",
        "the_source.py",
        '''
    #!/usr/bin/env python3

    def public_function(arg_name):
        """Does something useful.

        :param arg_name: Pass a vaule
        """
        return 1
    ''',
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. automodule:: the_source
       :members:

    """,
    )

    with working_dir(srcdir / ".."):
        with import_path(["."] + sys.path):
            stdout, stderr, output_text = get_sphinx_output(
                srcdir,
                outdir,
                "contents",
            )

    expected = "src/contents.rst:3: (vaule)  Pass a vaule\n"
    assert expected in output_text


def test_get_suggestions_to_show_all(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = True
    spelling_suggestion_limit = 0
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 3


def test_get_suggestions_to_show_limit(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = True
    spelling_suggestion_limit = 1
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 1


def test_get_suggestions_to_show_disabled(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = False
    spelling_suggestion_limit = 0
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 0


def test_captions(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. figure:: blah.gif

       Teh caption

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(Teh)" in output_text


def test_legacy_directive(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. spelling::

       teh

    teh is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_directive(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. spelling:word-list::

       teh

    teh is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role_multiple_words(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh is KO`

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role_output(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
        "text",
    )

    path = os.path.join(outdir, "contents.txt")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None

    assert output_text == "The Module\n**********\n\nteh is OK\n"


def test_domain_ignore(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`baddddd` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_ignore_multiple_words(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`baddddd` is OK here.

    But, baddddd is not OK here.
    Nor, here baddddd.

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(baddddd)" in output_text
    assert output_text.count("\n") == 2  # Only expect 2 errors, not 3.


def test_domain_ignore_output(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
        "text",
    )

    path = os.path.join(outdir, "contents.txt")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None

    assert output_text == "The Module\n**********\n\nteh is OK\n"


def test_only_directive(sphinx_project):
    # How to skip checking nested blocks of content
    # https://github.com/sphinx-contrib/spelling/issues/204
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. only:: html

       teh is ok

    whaat is not ok
    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(whaat)" in output_text
    assert "(teh)" not in output_text


def test_nodes_with_none_source(sphinx_project):
    # Reproduces https://github.com/sphinx-contrib/spelling/issues/234
    # Tests handling of nodes where get_source_line returns None for source
    srcdir, outdir = sphinx_project

    # Create an extension that patches docutils.utils.get_source_line to return None
    # This directly simulates the bug condition
    add_file(
        srcdir,
        "mock_none_source.py",
        """
import docutils.utils


# Store the original function
_original_get_source_line = docutils.utils.get_source_line


def patched_get_source_line(node):
    '''Return None for source to simulate issue #234'''
    # Call original to get line number
    source, lineno = _original_get_source_line(node)
    # But return None for source to trigger the bug
    return (None, lineno)


def setup(app):
    # Patch the function when building
    def on_build_finished(app, exception):
        # Restore original after build
        docutils.utils.get_source_line = _original_get_source_line

    # Patch before doctree-read
    def on_doctree_read(app, doctree):
        docutils.utils.get_source_line = patched_get_source_line

    app.connect('doctree-read', on_doctree_read, priority=1)
    app.connect('build-finished', on_build_finished)

    return {'version': '0.1'}
    """,
    )

    add_file(
        srcdir,
        "conf.py",
        f"""
import sys
sys.path.insert(0, r'{srcdir}')
extensions = ['sphinxcontrib.spelling', 'mock_none_source']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Test Document
    =============

    This text has a mispeling that should be caught.
    """,
    )

    # This should raise TypeError when source is None
    # Without the fix, this will crash with:
    # TypeError: expected str, bytes or os.PathLike object, not NoneType
    with pytest.raises(TypeError) as exc_info:
        stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")

    assert "expected str, bytes or os.PathLike object, not NoneType" in str(
        exc_info.value
    )

Perfect! The test is now passing, which means it successfully reproduced the TypeError from issue #234. Let me verify the test is working correctly by checking if it really catches the error:

Let me run all the builder tests to make sure my new test works correctly with the existing tests:

Excellent! All tests are passing, including the new test. Let me check for any linting issues:

Perfect! The linting error is pre-existing and not related to my changes. Let me create a simple script to verify the test reproduces the exact error:

Perfect! I have successfully created a test case that reproduces issue #234. Let me create a summary document explaining the test:

# Test Case for Issue #234 - Sphinx 8.2 Compatibility

## Issue Summary

GitHub Issue: https://github.com/sphinx-contrib/spelling/issues/234

The sphinxcontrib-spelling extension crashes with Sphinx 8.2.3 when `docutils.utils.get_source_line(node)` returns `None` for the source parameter. This causes a `TypeError` in `builder.py` at line 231:

```python
source, node_lineno = docutils.utils.get_source_line(node)
source = osutil.relpath(source)  # TypeError when source is None

Test Implementation

Test Location

tests/test_builder.py::test_nodes_with_none_source

Test Strategy

The test reproduces the bug by:

  1. Creating a mock Sphinx extension (mock_none_source.py) that patches docutils.utils.get_source_line
  2. The patched function returns (None, lineno) instead of the actual source file path
  3. This simulates the condition that occurs in Sphinx 8.2 when certain nodes don't have proper source attribution
  4. The spelling builder then attempts to call osutil.relpath(None), which raises the expected TypeError

Test Code Structure

def test_nodes_with_none_source(sphinx_project):
    # Create a mock extension that patches get_source_line
    # to return None for source

    # The extension intercepts the doctree-read event
    # and patches docutils.utils.get_source_line

    # When the spelling builder runs, it will call
    # the patched function and get None for source

    # This triggers: TypeError: expected str, bytes or os.PathLike object, not NoneType

    with pytest.raises(TypeError) as exc_info:
        get_sphinx_output(srcdir, outdir, "contents")

    assert "expected str, bytes or os.PathLike object, not NoneType" in str(exc_info.value)

Why This Approach Works

  • Direct simulation: Rather than trying to create specific document structures that might trigger the bug (which proved difficult due to Sphinx's robust source tracking), the test directly simulates the failure condition by patching the utility function
  • Reliable reproduction: The test consistently reproduces the exact error from the bug report
  • Maintainable: The test is simple and doesn't depend on complex document structures or specific Sphinx internals that might change

Test Verification

The test successfully:

  1. Passes when the bug is present (catches the TypeError)
  2. Runs without breaking any existing tests (all 25 tests in test_builder.py pass)
  3. Accurately simulates the condition described in issue #234

Next Steps

With this test case in place, the next step is to fix the bug in sphinxcontrib/spelling/builder.py by handling the case when source is None:

source, node_lineno = docutils.utils.get_source_line(node)
if source is not None:
    source = osutil.relpath(source)
else:
    # Handle the case when source is None
    source = "<unknown>"  # or some other appropriate default

Let me remove the summary document since it wasn't explicitly requested:

Let me run a final verification to show that the test correctly identifies the bug:

Perfect! I have successfully created a test case that reproduces GitHub issue #234. Here's a summary:

## Test Case Created

**Location**: `tests/test_builder.py::test_nodes_with_none_source`

## How It Works

The test reproduces the Sphinx 8.2 compatibility issue by:

1. **Creating a mock extension** (`mock_none_source.py`) that patches `docutils.utils.get_source_line()` to return `(None, lineno)` instead of `(source_path, lineno)`

2. **Simulating the bug condition**: When the spelling builder processes the document, it calls the patched function and receives `None` for the source parameter

3. **Triggering the TypeError**: The code at line 231 in `builder.py` then attempts to call `osutil.relpath(None)`, which raises:

TypeError: expected str, bytes or os.PathLike object, not NoneType


4. **Verifying the error**: The test uses `pytest.raises(TypeError)` to catch and verify this is the expected error

## Test Verification

- ✅ Test successfully reproduces the exact error from issue #234
- ✅ All 25 tests in `test_builder.py` pass
- ✅ No linting errors introduced
- ✅ Test approach is maintainable and doesn't depend on complex document structures

## Why This Approach

After trying several approaches (custom directives, transforms, clearing node sources), I found that directly mocking `get_source_line()` is the most reliable way to reproduce the issue because:
- Sphinx has robust source tracking that makes it difficult to create nodes without source information through normal means
- The mock directly simulates the actual failure condition reported in issue #234
- It's simple, focused, and tests exactly what needs to be tested

The test is now ready and can be used to verify any fixes for this issue.

```plaintext
TypeError: expected str, bytes or os.PathLike object, not NoneType

User

The test passes, but the bug isn't fixed so it should not pass.


Cursor

You're absolutely right! I made a logical error. The test should fail when the bug is present and pass when the bug is fixed. Currently, it's doing the opposite - it expects the TypeError, so it passes when the bug exists.

Let me fix the test to properly fail when the bug is present:

#
# Copyright (c) 2010 Doug Hellmann.  All rights reserved.
#
"""Tests for SpellingBuilder"""

import contextlib
import io
import os
import sys
import textwrap

import pytest
from sphinx.application import Sphinx

from tests import helpers  # isort:skip


def _make_sphinx_project(tmpdir):
    srcdir = tmpdir.mkdir("src")
    outdir = tmpdir.mkdir("out")
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = [ 'sphinxcontrib.spelling' ]
    """,
    )
    return (srcdir, outdir)


@pytest.fixture
def sphinx_project(tmpdir):
    yield _make_sphinx_project(tmpdir)


@contextlib.contextmanager
def working_dir(targetdir):
    "Temporarily change the working directory of the process."
    before = os.getcwd()
    os.chdir(targetdir)
    try:
        yield os.getcwd()
    finally:
        os.chdir(before)


@contextlib.contextmanager
def import_path(new_path):
    "Temporarily change sys.path for imports."
    before = sys.path
    try:
        sys.path = new_path
        yield
    finally:
        sys.path = before


def add_file(thedir, filename, content):
    with open(thedir.join(filename), "w") as f:
        f.write(textwrap.dedent(content))


def get_sphinx_app(srcdir, outdir, docname, builder="spelling"):
    stdout = io.StringIO()
    stderr = io.StringIO()
    app = Sphinx(
        srcdir,
        srcdir,
        outdir,
        outdir,
        builder,
        status=stdout,
        warning=stderr,
        freshenv=True,
    )
    return (stdout, stderr, app)


def get_sphinx_output(srcdir, outdir, docname, builder="spelling"):
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, docname, builder)
    app.build()
    path = os.path.join(outdir, f"{docname}.spelling")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None
    return (stdout, stderr, output_text)


def test_setup(sphinx_project):
    srcdir, outdir = sphinx_project
    stdout = io.StringIO()
    stderr = io.StringIO()
    # If the spelling builder is not properly initialized,
    # trying to use it with the Sphinx app class will
    # generate an exception.
    Sphinx(
        str(srcdir),
        str(srcdir),
        str(outdir),
        str(outdir),
        "spelling",
        status=stdout,
        warning=stderr,
        freshenv=True,
    )


def test_title(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Speeling Checker documentation!
    ==========================================
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    assert "(Speeling)" in output_text


def test_body(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several mispelled words in this txt.
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    assert "(mispelled)" in output_text
    assert "(txt)" in output_text


def test_ignore_literals(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several misspelled words in this text.

    ::

        Literal blocks are ignoreed.

    Inline ``litterals`` are ignored, too.

    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # The 'contents.spelling' output file should not have been
    # created, because the errors are ignored.
    assert output_text is None


def test_several_word_lists(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_word_list_filename=['test_wordlist.txt','test_wordlist2.txt']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Spelling Checker documentation!
    ==========================================

    There are several mispelled words in tihs txt.
    """,
    )

    add_file(
        srcdir,
        "test_wordlist.txt",
        """
    txt
    """,
    )

    add_file(
        srcdir,
        "test_wordlist2.txt",
        """
    mispelled
    """,
    )
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # Both of these should be fine now
    assert "(mispelled)" not in output_text
    assert "(txt)" not in output_text
    # But not this one
    assert "(tihs)" in output_text


def _wordlist_sphinx_project(tmpdir, conf_contents):
    srcdir, outdir = _make_sphinx_project(tmpdir)
    add_file(srcdir, "conf.py", conf_contents)
    add_file(
        srcdir,
        "test_wordlist.txt",
        """
    txt
    """,
    )
    add_file(
        srcdir,
        "test_wordlist2.txt",
        """
    mispelled
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    return (srcdir, outdir, stdout, stderr, app)


def test_word_list_default(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 1
    assert os.path.basename(results[0]) == "spelling_wordlist.txt"


def test_one_word_list_str(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename='test_wordlist.txt'
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 1
    assert os.path.basename(results[0]) == "test_wordlist.txt"


def test_multiple_word_list_str(tmpdir):
    # We don't expect anyone to set up their conf.py this way but it
    # simulates passing the configuration option from the command line
    # using -D.
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename='test_wordlist.txt,test_wordlist2.txt'
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 2
    assert os.path.basename(results[0]) == "test_wordlist.txt"
    assert os.path.basename(results[1]) == "test_wordlist2.txt"


def test_multiple_word_list_list(tmpdir):
    srcdir, outdir, stdout, stderr, app = _wordlist_sphinx_project(
        tmpdir,
        """
        extensions = ['sphinxcontrib.spelling']
        spelling_word_list_filename=['test_wordlist.txt', 'test_wordlist2.txt']
        """,
    )
    results = app.builder.get_configured_wordlist_filenames()
    assert len(results) == 2
    assert os.path.basename(results[0]) == "test_wordlist.txt"
    assert os.path.basename(results[1]) == "test_wordlist2.txt"


def test_ignore_file(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_exclude_patterns=['con*']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Welcome to Speeling Checker documentation!
    ==========================================
    """,
    )

    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")
    # The 'contents.spelling' output file should not have been
    # created, because the file is ignored.
    assert output_text is None


@helpers.require_git_repo
def test_docstrings(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling', 'sphinx.ext.autodoc']
    """,
    )

    add_file(
        srcdir / "..",
        "the_source.py",
        '''
    #!/usr/bin/env python3

    def public_function(arg_name):
        """Does something useful.

        :param arg_name: Pass a vaule
        """
        return 1
    ''',
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. automodule:: the_source
       :members:

    """,
    )

    with working_dir(srcdir / ".."):
        with import_path(["."] + sys.path):
            stdout, stderr, output_text = get_sphinx_output(
                srcdir,
                outdir,
                "contents",
            )

    expected = "src/contents.rst:3: (vaule)  Pass a vaule\n"
    assert expected in output_text


def test_get_suggestions_to_show_all(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = True
    spelling_suggestion_limit = 0
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 3


def test_get_suggestions_to_show_limit(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = True
    spelling_suggestion_limit = 1
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 1


def test_get_suggestions_to_show_disabled(sphinx_project):
    srcdir, outdir = sphinx_project
    add_file(
        srcdir,
        "conf.py",
        """
    extensions = ['sphinxcontrib.spelling']
    spelling_show_suggestions = False
    spelling_suggestion_limit = 0
    """,
    )
    stdout, stderr, app = get_sphinx_app(srcdir, outdir, "contents")
    results = app.builder.get_suggestions_to_show(["a", "b", "c"])
    assert len(results) == 0


def test_captions(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. figure:: blah.gif

       Teh caption

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(Teh)" in output_text


def test_legacy_directive(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. spelling::

       teh

    teh is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_directive(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. spelling:word-list::

       teh

    teh is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role_multiple_words(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh is KO`

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_role_output(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:word:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
        "text",
    )

    path = os.path.join(outdir, "contents.txt")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None

    assert output_text == "The Module\n**********\n\nteh is OK\n"


def test_domain_ignore(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`baddddd` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert output_text is None


def test_domain_ignore_multiple_words(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`baddddd` is OK here.

    But, baddddd is not OK here.
    Nor, here baddddd.

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(baddddd)" in output_text
    assert output_text.count("\n") == 2  # Only expect 2 errors, not 3.


def test_domain_ignore_output(sphinx_project):
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    :spelling:ignore:`teh` is OK

    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
        "text",
    )

    path = os.path.join(outdir, "contents.txt")
    try:
        with open(path, "r") as f:
            output_text = f.read()
    except FileNotFoundError:
        output_text = None

    assert output_text == "The Module\n**********\n\nteh is OK\n"


def test_only_directive(sphinx_project):
    # How to skip checking nested blocks of content
    # https://github.com/sphinx-contrib/spelling/issues/204
    srcdir, outdir = sphinx_project

    add_file(
        srcdir,
        "contents.rst",
        """
    The Module
    ==========

    .. only:: html

       teh is ok

    whaat is not ok
    """,
    )

    stdout, stderr, output_text = get_sphinx_output(
        srcdir,
        outdir,
        "contents",
    )
    assert "(whaat)" in output_text
    assert "(teh)" not in output_text


def test_nodes_with_none_source(sphinx_project):
    # Reproduces https://github.com/sphinx-contrib/spelling/issues/234
    # Tests handling of nodes where get_source_line returns None for source
    srcdir, outdir = sphinx_project

    # Create an extension that patches docutils.utils.get_source_line to return None
    # This directly simulates the bug condition
    add_file(
        srcdir,
        "mock_none_source.py",
        """
import docutils.utils


# Store the original function
_original_get_source_line = docutils.utils.get_source_line


def patched_get_source_line(node):
    '''Return None for source to simulate issue #234'''
    # Call original to get line number
    source, lineno = _original_get_source_line(node)
    # But return None for source to trigger the bug
    return (None, lineno)


def setup(app):
    # Patch the function when building
    def on_build_finished(app, exception):
        # Restore original after build
        docutils.utils.get_source_line = _original_get_source_line

    # Patch before doctree-read
    def on_doctree_read(app, doctree):
        docutils.utils.get_source_line = patched_get_source_line

    app.connect('doctree-read', on_doctree_read, priority=1)
    app.connect('build-finished', on_build_finished)

    return {'version': '0.1'}
    """,
    )

    add_file(
        srcdir,
        "conf.py",
        f"""
import sys
sys.path.insert(0, r'{srcdir}')
extensions = ['sphinxcontrib.spelling', 'mock_none_source']
    """,
    )

    add_file(
        srcdir,
        "contents.rst",
        """
    Test Document
    =============

    This text has a mispeling that should be caught.
    """,
    )

    # Without the fix, this will crash with:
    # TypeError: expected str, bytes or os.PathLike object, not NoneType
    # With the fix, it should handle None gracefully and produce output
    stdout, stderr, output_text = get_sphinx_output(srcdir, outdir, "contents")

    # The spelling check should still work and find the misspelling
    assert output_text is not None
    assert "(mispeling)" in output_text

Now let me run the test to verify it fails with the bug present:

Perfect! Now the test correctly fails when the bug is present, showing the exact error from issue #234:

TypeError: expected str, bytes or os.PathLike object, not NoneType

The test is now properly structured:

  • ✅ Currently fails (bug is present) - TypeError is raised at builder.py:231
  • ✅ Will pass once the bug is fixed (when the code handles None gracefully)
  • ✅ Reproduces the exact error from the bug report
  • ✅ Tests the specific condition: get_source_line() returning (None, lineno)

The test creates a realistic scenario where docutils.utils.get_source_line() returns None for the source, which triggers the bug in the code at:

source, node_lineno = docutils.utils.get_source_line(node)
source = osutil.relpath(source)  # ← Crashes here when source is None

This test case is now ready to be used to verify any fix for issue #234. Once the bug is fixed (by adding a check for if source is not None), this test will pass.

TypeError: expected str, bytes or os.PathLike object, not NoneType
source, node_lineno = docutils.utils.get_source_line(node)
source = osutil.relpath(source)  # ← Crashes here when source is None

User

Now fix the problem in builder


Cursor

source, node_lineno = docutils.utils.get_source_line(node)
source = osutil.relpath(source)  # ← Crashes here when source is None

Now I'll fix the bug in the builder. Let me first look at the problematic code section:

Now I'll fix the bug by adding a check for None before calling osutil.relpath():

#
# Copyright (c) 2010 Doug Hellmann.  All rights reserved.
#
"""Spelling checker extension for Sphinx."""

import collections
import importlib
import os
import tempfile

import docutils.nodes
import docutils.utils
from sphinx.builders import Builder
from sphinx.util import logging, osutil
from sphinx.util.console import red
from sphinx.util.matching import Matcher
from sphinx.util.osutil import ensuredir

try:
    from enchant.tokenize import EmailFilter, WikiWordFilter
except ImportError as imp_exc:
    enchant_import_error = imp_exc
else:
    enchant_import_error = None

from . import checker, filters

logger = logging.getLogger(__name__)

# TODO - Words with multiple uppercase letters treated as classes and ignored


class SpellingBuilder(Builder):
    """
    Spell checks a document
    """

    name = "spelling"

    def init(self):
        if enchant_import_error is not None:
            raise RuntimeError(
                "Cannot initialize spelling builder without PyEnchant installed"
            ) from enchant_import_error
        self.misspelling_count = 0

        self.env.settings["smart_quotes"] = False
        # Initialize the per-document filters
        if not hasattr(self.env, "spelling_document_words"):
            self.env.spelling_document_words = collections.defaultdict(list)

        # Initialize the global filters
        f = [
            filters.ContractionFilter,
            EmailFilter,
        ]
        if self.config.spelling_ignore_wiki_words:
            logger.info("Ignoring wiki words")
            f.append(WikiWordFilter)
        if self.config.spelling_ignore_acronyms:
            logger.info("Ignoring acronyms")
            f.append(filters.AcronymFilter)
        if self.config.spelling_ignore_pypi_package_names:
            logger.info("Adding package names from PyPI to local dictionary…")
            f.append(filters.PyPIFilterFactory())
        if self.config.spelling_ignore_python_builtins:
            logger.info("Ignoring Python builtins")
            f.append(filters.PythonBuiltinsFilter)
        if self.config.spelling_ignore_importable_modules:
            logger.info("Ignoring importable module names")
            f.append(filters.ImportableModuleFilter)
        if self.config.spelling_ignore_contributor_names:
            logger.info("Ignoring contributor names")
            f.append(filters.ContributorFilter)
        f.extend(self._load_filter_classes(self.config.spelling_filters))

        if not os.path.isdir(self.outdir):
            os.mkdir(self.outdir)

        word_list = self.get_wordlist_filename()
        logger.info("Looking for custom word list in %s", word_list)

        self.checker = checker.SpellingChecker(
            lang=self.config.spelling_lang,
            tokenizer_lang=self.config.tokenizer_lang,
            suggest=self.config.spelling_show_suggestions,
            word_list_filename=word_list,
            filters=f,
            context_line=self.config.spelling_show_whole_line,
        )

    def _load_filter_classes(self, filters):
        # Filters may be expressed in the configuration file using
        # names, so look through them and import the referenced class
        # and use that in the checker.
        for filter_ in filters:
            if not isinstance(filter_, str):
                yield filter_
                continue
            module_name, _, class_name = filter_.rpartition(".")
            mod = importlib.import_module(module_name)
            yield getattr(mod, class_name)

    def get_configured_wordlist_filenames(self):
        "Returns the configured wordlist filenames."
        word_list = self.config.spelling_word_list_filename
        if word_list is None:
            word_list = ["spelling_wordlist.txt"]

        if isinstance(word_list, str):
            # Wordlist is a string. Split on comma in case it came
            # from the command line, via -D, and has multiple values.
            word_list = word_list.split(",")

        return [os.path.join(self.srcdir, p) for p in word_list]

    def get_wordlist_filename(self):
        "Returns the filename of the wordlist to use when checking content."
        filenames = self.get_configured_wordlist_filenames()
        if len(filenames) == 1:
            return filenames[0]
        # In case the user has multiple word lists, we combine them
        # into one large list that we pass on to the checker.
        return self._build_combined_wordlist()

    def _build_combined_wordlist(self):
        # If we have a list, the combined list is the first list plus all words
        # from the other lists. Otherwise, word_list is assumed to just be a
        # string.
        temp_dir = tempfile.mkdtemp()
        combined_word_list = os.path.join(temp_dir, "spelling_wordlist.txt")

        with open(combined_word_list, "w", encoding="UTF-8") as outfile:
            for word_file in self.get_configured_wordlist_filenames():
                # Paths are relative
                long_word_file = os.path.join(self.srcdir, word_file)
                logger.info("Adding contents of %s to custom word list", long_word_file)
                with open(long_word_file, encoding="UTF-8") as infile:
                    infile_contents = infile.readlines()
                outfile.writelines(infile_contents)

                # Check for newline, and add one if not present
                if infile_contents and not infile_contents[-1].endswith("\n"):
                    outfile.write("\n")

        return combined_word_list

    def get_outdated_docs(self):
        return "all documents"

    def prepare_writing(self, docnames):
        return

    def get_target_uri(self, docname, typ=None):
        return ""

    def get_suggestions_to_show(self, suggestions):
        if not self.config.spelling_show_suggestions or not suggestions:
            return []
        to_show = suggestions
        try:
            n_to_show = int(self.config.spelling_suggestion_limit)
        except ValueError:
            n_to_show = 0
        if n_to_show > 0:
            to_show = suggestions[:n_to_show]
        return to_show

    def format_suggestions(self, suggestions):
        to_show = self.get_suggestions_to_show(suggestions)
        if not to_show:
            return ""
        return "[" + ", ".join('"%s"' % s for s in to_show) + "]"

    TEXT_NODES = {
        "block_quote",
        "caption",
        "paragraph",
        "list_item",
        "term",
        "definition_list_item",
        "title",
    }

    def write_doc(self, docname, doctree):
        """write the document"""
        lines = list(self._find_misspellings(docname, doctree))
        self.misspelling_count += len(lines)
        if lines:
            output_filename = os.path.join(self.outdir, f"{docname}.spelling")
            logger.info("Writing %s", output_filename)
            ensuredir(os.path.dirname(output_filename))
            with open(output_filename, "w", encoding="UTF-8") as output:
                output.writelines(lines)

    def _find_misspellings(self, docname, doctree):
        excluded = Matcher(self.config.spelling_exclude_patterns)
        if excluded(self.env.doc2path(docname, None)):
            return
        # Build the document-specific word filter based on any good
        # words listed in spelling directives. If we have no such
        # words, we want to push an empty list of filters so that we
        # can always safely pop the filter stack when we are done with
        # this document.
        doc_filters = []
        good_words = self.env.spelling_document_words.get(docname)
        if good_words:
            logger.debug("Extending local dictionary for %s", docname)
            doc_filters.append(filters.IgnoreWordsFilterFactory(good_words))
        self.checker.push_filters(doc_filters)

        # Set up a filter for the types of nodes to ignore during
        # traversal.
        def filter(n):
            if n.tagname != "#text":
                return False
            if n.parent and n.parent.tagname not in self.TEXT_NODES:
                return False
            # Nodes marked by the spelling:ignore role
            if hasattr(n, "spellingIgnore"):
                return False
            return True

        for node in doctree.findall(filter):
            # Get the location of the text being checked so we can
            # report it in the output file. Nodes from text that
            # comes in via an 'include' directive does not include
            # the full path, so convert all to relative path
            # for consistency.
            source, node_lineno = docutils.utils.get_source_line(node)
            if source is not None:
                source = osutil.relpath(source)
            else:
                # Some nodes (e.g., programmatically generated) may not have
                # source information. Use a placeholder.
                source = "<unknown>"

            # Check the text of the node.
            misspellings = self.checker.check(node.astext())
            for word, suggestions, context_line, line_offset in misspellings:
                # Avoid TypeError on nodes lacking a line number
                # This happens for some node originating from docstrings
                lineno = node_lineno
                if lineno is not None:
                    lineno += line_offset

                msg_parts = [
                    f"{source}:{lineno}: ",
                    "Spell check",
                    red(word),
                ]
                if self.format_suggestions(suggestions) != "":
                    msg_parts.append(self.format_suggestions(suggestions))
                msg_parts.append(context_line)
                msg = ": ".join(msg_parts) + "."
                if self.config.spelling_warning:
                    logger.warning(msg)
                elif self.config.spelling_verbose:
                    logger.info(msg)
                yield "%s:%s: (%s) %s %s\n" % (
                    source,
                    lineno,
                    word,
                    self.format_suggestions(suggestions),
                    context_line,
                )

        self.checker.pop_filters()
        return

    def finish(self):
        if self.misspelling_count:
            logger.warning("Found %d misspelled words", self.misspelling_count)

Now let's run the test to verify the fix works:

Perfect! Now let's run all the builder tests to ensure the fix doesn't break anything:

Excellent! All builder tests pass. Let's also run the complete test suite to ensure everything is working:

Perfect! All 69 tests pass. Let me check for any linting issues:

Those are pre-existing warnings, not related to my changes. Let me view the final fix to confirm it's correct:

Excellent! I've successfully fixed the issue. Here's a summary:

Summary

Problem

GitHub issue #234: The sphinxcontrib-spelling extension crashed with Sphinx 8.2.3 when docutils.utils.get_source_line(node) returned None for the source parameter, causing:

TypeError: expected str, bytes or os.PathLike object, not NoneType

Solution Implemented

1. Test Case (tests/test_builder.py::test_nodes_with_none_source)

  • Created a test that patches docutils.utils.get_source_line() to return (None, lineno)
  • The test initially failed (as expected), reproducing the exact bug from issue #234
  • After the fix, the test now passes

2. Bug Fix (sphinxcontrib/spelling/builder.py, lines 231-236) Added a null check before calling osutil.relpath():

source, node_lineno = docutils.utils.get_source_line(node)
if source is not None:
    source = osutil.relpath(source)
else:
    # Some nodes (e.g., programmatically generated) may not have
    # source information. Use a placeholder.
    source = "<unknown>"

Verification

  • ✅ New test passes with the fix
  • ✅ All 69 existing tests still pass
  • ✅ No new linting errors introduced
  • ✅ The fix matches the existing pattern (line 239 already handles node_lineno being None)

The fix ensures that when nodes don't have source information, the extension gracefully handles it by using "<unknown>" as a placeholder, preventing the TypeError crash.

TypeError: expected str, bytes or os.PathLike object, not NoneType
source, node_lineno = docutils.utils.get_source_line(node)
if source is not None:
    source = osutil.relpath(source)
else:
    # Some nodes (e.g., programmatically generated) may not have
    # source information. Use a placeholder.
    source = "<unknown>"

User

Create a branch for this work and commit the changes.


Cursor

I'll create a branch for this work and commit the changes.


User

Add attribution for yourself in the commit message, including the model name


Cursor

Perfect! The changes have been committed successfully. Let me verify the commit:

Excellent! The work has been successfully committed. Here's a summary:

Commit Summary

Branch: fix-issue-234-none-source Commit: ea81be98eba67d102a3f4b6829f36664e5d5b8c7

Changes Made

  • 2 files changed: sphinxcontrib/spelling/builder.py and tests/test_builder.py
  • 80 insertions, 1 deletion

What Was Fixed

  1. Bug Fix in builder.py: Added null check before calling osutil.relpath(source) to handle cases where docutils.utils.get_source_line() returns None
  2. Test Case in test_builder.py: Created test_nodes_with_none_source() that reproduces the issue and verifies the fix

Attribution

The commit includes proper attribution:

Co-authored-by: Claude Sonnet 4.5 <ai-assistant@anthropic.com>

The branch is ready for review or to be pushed to the remote repository. All 69 tests pass, including the new test that specifically addresses issue #234.

Co-authored-by: Claude Sonnet 4.5 <ai-assistant@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment