Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 39 additions & 6 deletions sphinx/domains/std/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import operator
import os.path
import re
from copy import copy
from typing import TYPE_CHECKING, cast
Expand Down Expand Up @@ -778,6 +779,7 @@ class StandardDomain(Domain):
'modindex': ('py-modindex', ''),
'search': ('search', ''),
},
'labels_source': {}, # labelname -> source file path
}

# labelname -> docname, sectionname
Expand Down Expand Up @@ -893,6 +895,12 @@ def labels(self) -> dict[str, tuple[str, str, str]]:
def anonlabels(self) -> dict[str, tuple[str, str]]:
return self.data.setdefault('anonlabels', {}) # labelname -> docname, labelid

@property
def labels_source(self) -> dict[str, str]:
return self.data.setdefault(
'labels_source', {}
) # labelname -> source file path

def clear_doc(self, docname: str) -> None:
to_remove1 = [
key for key, (fn, _l) in self.progoptions.items() if fn == docname
Expand All @@ -911,6 +919,7 @@ def clear_doc(self, docname: str) -> None:
to_remove3 = [key for key, (fn, _l, _l) in self.labels.items() if fn == docname]
for key3 in to_remove3:
del self.labels[key3]
self.labels_source.pop(key3, None)

to_remove3 = [key for key, (fn, _l) in self.anonlabels.items() if fn == docname]
for key3 in to_remove3:
Expand All @@ -933,6 +942,9 @@ def merge_domaindata(self, docnames: Set[str], otherdata: dict[str, Any]) -> Non
for key, data in otherdata['anonlabels'].items():
if data[0] in docnames:
self.anonlabels[key] = data
for key, data in otherdata.get('labels_source', {}).items():
if key in self.labels:
self.labels_source[key] = data

def process_doc(
self, env: BuildEnvironment, docname: str, document: nodes.document
Expand All @@ -957,12 +969,30 @@ def process_doc(
# link and object descriptions
continue
if name in self.labels:
logger.warning(
__('duplicate label %s, other instance in %s'),
name,
env.doc2path(self.labels[name][0]),
location=node,
)
current_source = getattr(node, 'source', None)
existing_source = self.labels_source.get(name)
existing_docname = self.labels[name][0]

# Check if both labels come from the same source file
if current_source and existing_source:
current_norm = os.path.normcase(os.path.normpath(current_source))
existing_norm = os.path.normcase(os.path.normpath(existing_source))
same_source = current_norm == existing_norm
else:
same_source = False

if same_source:
# Same source file included in multiple documents.
# Prefer the including document for proper figure numbering.
if existing_docname not in env.included.get(docname, set()):
continue
else:
logger.warning(
__('duplicate label %s, other instance in %s'),
name,
env.doc2path(existing_docname),
location=node,
)
self.anonlabels[name] = docname, labelid
if node.tagname == 'section':
title = cast('nodes.title', node[0])
Expand Down Expand Up @@ -991,6 +1021,9 @@ def process_doc(
# anonymous-only labels
continue
self.labels[name] = docname, labelid, sectname
node_source = getattr(node, 'source', None)
if node_source:
self.labels_source[name] = node_source

def add_program_option(
self, program: str | None, name: str, docname: str, labelid: str
Expand Down
3 changes: 3 additions & 0 deletions tests/roots/test-numfig-include-duplicate/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
project = 'test-numfig-include-duplicate'
exclude_patterns = ['_build']
numfig = True
8 changes: 8 additions & 0 deletions tests/roots/test-numfig-include-duplicate/doc1.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Document 1
==========

.. _shared-label:

.. figure:: img.png

Figure in doc1
8 changes: 8 additions & 0 deletions tests/roots/test-numfig-include-duplicate/doc2.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Document 2
==========

.. _shared-label:

.. figure:: img.png

Figure in doc2 (DUPLICATE label from different source file)
1 change: 1 addition & 0 deletions tests/roots/test-numfig-include-duplicate/img.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions tests/roots/test-numfig-include-duplicate/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Test duplicate labels across documents
======================================

.. toctree::

doc1
doc2
15 changes: 15 additions & 0 deletions tests/roots/test-numfig-include/_includes/figures.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.. _included-figure:

.. figure:: /img.png

This is an included figure

See :numref:`included-figure` for the figure above.

.. _included-figure-2:

.. figure:: /img.png

This is another included figure

See :numref:`included-figure-2` for figure 2.
5 changes: 5 additions & 0 deletions tests/roots/test-numfig-include/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
project = 'test-numfig-include'
exclude_patterns = [
'_build'
] # NOT excluding _includes to test include-aware duplicate handling
numfig = True
1 change: 1 addition & 0 deletions tests/roots/test-numfig-include/img.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions tests/roots/test-numfig-include/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
Test numfig with include
========================

This document tests that :numref: works with figures in included files.

.. include:: _includes/figures.rst

Reference to included figure: :numref:`included-figure`

Reference to local figure: :numref:`local-figure`

.. _local-figure:

.. figure:: img.png

This is a local figure
95 changes: 95 additions & 0 deletions tests/test_domains/test_domain_std_include.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Tests for StandardDomain handling of labels in included files."""

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest

if TYPE_CHECKING:
from pathlib import Path

from sphinx.testing.util import SphinxTestApp


@pytest.mark.sphinx('html', testroot='numfig-include')
def test_numref_in_included_file_no_warning(app: SphinxTestApp) -> None:
"""Test that numref works with included files without duplicate label warnings.

When a file is included via the include directive and also processed as a
standalone document (not in exclude_patterns), labels should not produce
duplicate warnings if they come from the same source via include relationship.

See https://github.com/sphinx-doc/sphinx/issues/14413
See https://github.com/sphinx-doc/sphinx/issues/9779
"""
app.build()
warnings = app.warning.getvalue()

# Should not have duplicate label warnings for labels in included files
assert 'duplicate label included-figure' not in warnings
assert 'duplicate label included-figure-2' not in warnings


@pytest.mark.sphinx('html', testroot='numfig-include', freshenv=True)
def test_numref_in_included_file_correct_numbering(app: SphinxTestApp) -> None:
"""Test that figure numbering is correct when files are included."""
app.build()

# Check the output HTML for correct figure numbering
index_html = (app.outdir / 'index.html').read_text(encoding='utf-8')

# Figures should be numbered sequentially
assert 'Fig. 1' in index_html
assert 'Fig. 2' in index_html
assert 'Fig. 3' in index_html

# Numref links should resolve correctly
assert '<span class="std std-numref">Fig. 1</span>' in index_html
assert '<span class="std std-numref">Fig. 2</span>' in index_html
assert '<span class="std std-numref">Fig. 3</span>' in index_html


@pytest.mark.sphinx('html', testroot='numfig-include-duplicate', freshenv=True)
def test_real_duplicate_label_still_warns(app: SphinxTestApp) -> None:
"""Test that real duplicate labels (different source files) still produce warnings.

When two separate documents (doc1.rst and doc2.rst) both define the same label,
this is a real duplicate and Sphinx's StandardDomain should warn.
This ensures we don't over-suppress duplicate warnings.
"""
app.build()
warnings = app.warning.getvalue()

# Should have duplicate label warning because this is a REAL duplicate
# (label 'shared-label' defined in both doc1.rst AND doc2.rst)
assert 'duplicate label shared-label' in warnings


@pytest.mark.sphinx('html', testroot='numfig-include', freshenv=True)
def test_incremental_build_no_stale_labels(app: SphinxTestApp, tmp_path: Path) -> None:
"""Test that incremental builds work correctly with included files.

When a document is rebuilt incrementally, the duplicate detection should
continue to work properly without false warnings.
"""
import time

# Initial build
app.build()
warnings = app.warning.getvalue()
assert 'duplicate label' not in warnings

# Touch the index file to trigger incremental rebuild
index_file = app.srcdir / 'index.rst'
time.sleep(0.1) # Ensure mtime changes
index_file.write_text(index_file.read_text(encoding='utf-8'), encoding='utf-8')

# Clear warnings and rebuild
app.warning.truncate(0)
app.warning.seek(0)
app.build()

# Should still not have duplicate warnings after incremental rebuild
warnings = app.warning.getvalue()
assert 'duplicate label' not in warnings
Loading