Skip to content

Commit 587da41

Browse files
authored
Warn when files are overwritten in the build directory (#12612)
1 parent 3c5ed2b commit 587da41

File tree

10 files changed

+106
-13
lines changed

10 files changed

+106
-13
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ Release 7.4.7 (in development)
44
Bugs fixed
55
----------
66

7+
* #12096: Warn when files are overwritten in the build directory.
8+
Patch by Adam Turner.
79

810
Release 7.4.6 (released Jul 18, 2024)
911
=====================================

sphinx/builders/html/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -815,8 +815,8 @@ def copy_stemmer_js(self) -> None:
815815

816816
def copy_theme_static_files(self, context: dict[str, Any]) -> None:
817817
def onerror(filename: str, error: Exception) -> None:
818-
logger.warning(__('Failed to copy a file in html_static_file: %s: %r'),
819-
filename, error)
818+
msg = __("Failed to copy a file in the theme's 'static' directory: %s: %r")
819+
logger.warning(msg, filename, error)
820820

821821
if self.theme:
822822
for entry in reversed(self.theme.get_theme_dirs()):
@@ -1142,7 +1142,8 @@ def js_tag(js: _JavaScript | str) -> str:
11421142
source_name = path.join(self.outdir, '_sources',
11431143
os_path(ctx['sourcename']))
11441144
ensuredir(path.dirname(source_name))
1145-
copyfile(self.env.doc2path(pagename), source_name)
1145+
copyfile(self.env.doc2path(pagename), source_name,
1146+
__overwrite_warning__=False)
11461147

11471148
def update_page_context(self, pagename: str, templatename: str,
11481149
ctx: dict[str, Any], event_arg: Any) -> None:

sphinx/util/fileutil.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88

99
from docutils.utils import relative_path
1010

11+
from sphinx.util import logging
1112
from sphinx.util.osutil import copyfile, ensuredir
1213

1314
if TYPE_CHECKING:
1415
from sphinx.util.template import BaseRenderer
1516
from sphinx.util.typing import PathMatcher
1617

18+
logger = logging.getLogger(__name__)
19+
1720

1821
def _template_basename(filename: str | os.PathLike[str]) -> str | None:
1922
"""Given an input filename:
@@ -30,7 +33,8 @@ def _template_basename(filename: str | os.PathLike[str]) -> str | None:
3033

3134
def copy_asset_file(source: str | os.PathLike[str], destination: str | os.PathLike[str],
3235
context: dict[str, Any] | None = None,
33-
renderer: BaseRenderer | None = None) -> None:
36+
renderer: BaseRenderer | None = None,
37+
*, __overwrite_warning__: bool = True) -> None:
3438
"""Copy an asset file to destination.
3539
3640
On copying, it expands the template variables if context argument is given and
@@ -56,17 +60,36 @@ def copy_asset_file(source: str | os.PathLike[str], destination: str | os.PathLi
5660
renderer = SphinxRenderer()
5761

5862
with open(source, encoding='utf-8') as fsrc:
59-
destination = _template_basename(destination) or destination
60-
with open(destination, 'w', encoding='utf-8') as fdst:
61-
fdst.write(renderer.render_string(fsrc.read(), context))
63+
template_content = fsrc.read()
64+
rendered_template = renderer.render_string(template_content, context)
65+
66+
if (
67+
__overwrite_warning__
68+
and os.path.exists(destination)
69+
and template_content != rendered_template
70+
):
71+
# Consider raising an error in Sphinx 8.
72+
# Certainly make overwriting user content opt-in.
73+
# xref: RemovedInSphinx80Warning
74+
# xref: https://github.com/sphinx-doc/sphinx/issues/12096
75+
msg = ('Copying the rendered template %s to %s will overwrite data, '
76+
'as a file already exists at the destination path '
77+
'and the content does not match.')
78+
logger.info(msg, os.fsdecode(source), os.fsdecode(destination),
79+
type='misc', subtype='copy_overwrite')
80+
81+
destination = _template_basename(destination) or destination
82+
with open(destination, 'w', encoding='utf-8') as fdst:
83+
fdst.write(rendered_template)
6284
else:
63-
copyfile(source, destination)
85+
copyfile(source, destination, __overwrite_warning__=__overwrite_warning__)
6486

6587

6688
def copy_asset(source: str | os.PathLike[str], destination: str | os.PathLike[str],
6789
excluded: PathMatcher = lambda path: False,
6890
context: dict[str, Any] | None = None, renderer: BaseRenderer | None = None,
69-
onerror: Callable[[str, Exception], None] | None = None) -> None:
91+
onerror: Callable[[str, Exception], None] | None = None,
92+
*, __overwrite_warning__: bool = True) -> None:
7093
"""Copy asset files to destination recursively.
7194
7295
On copying, it expands the template variables if context argument is given and
@@ -88,7 +111,8 @@ def copy_asset(source: str | os.PathLike[str], destination: str | os.PathLike[st
88111

89112
ensuredir(destination)
90113
if os.path.isfile(source):
91-
copy_asset_file(source, destination, context, renderer)
114+
copy_asset_file(source, destination, context, renderer,
115+
__overwrite_warning__=__overwrite_warning__)
92116
return
93117

94118
for root, dirs, files in os.walk(source, followlinks=True):
@@ -104,7 +128,8 @@ def copy_asset(source: str | os.PathLike[str], destination: str | os.PathLike[st
104128
try:
105129
copy_asset_file(posixpath.join(root, filename),
106130
posixpath.join(destination, reldir),
107-
context, renderer)
131+
context, renderer,
132+
__overwrite_warning__=__overwrite_warning__)
108133
except Exception as exc:
109134
if onerror:
110135
onerror(posixpath.join(root, filename), exc)

sphinx/util/osutil.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,12 @@ def copytimes(source: str | os.PathLike[str], dest: str | os.PathLike[str]) -> N
8888
os.utime(dest, (st.st_atime, st.st_mtime))
8989

9090

91-
def copyfile(source: str | os.PathLike[str], dest: str | os.PathLike[str]) -> None:
91+
def copyfile(
92+
source: str | os.PathLike[str],
93+
dest: str | os.PathLike[str],
94+
*,
95+
__overwrite_warning__: bool = True,
96+
) -> None:
9297
"""Copy a file and its modification times, if possible.
9398
9499
:param source: An existing source to copy.
@@ -101,7 +106,19 @@ def copyfile(source: str | os.PathLike[str], dest: str | os.PathLike[str]) -> No
101106
msg = f'{os.fsdecode(source)} does not exist'
102107
raise FileNotFoundError(msg)
103108

104-
if not path.exists(dest) or not filecmp.cmp(source, dest):
109+
if not (dest_exists := path.exists(dest)) or not filecmp.cmp(source, dest):
110+
if __overwrite_warning__ and dest_exists:
111+
# sphinx.util.logging imports sphinx.util.osutil,
112+
# so use a local import to avoid circular imports
113+
from sphinx.util import logging
114+
logger = logging.getLogger(__name__)
115+
116+
msg = ('Copying the source path %s to %s will overwrite data, '
117+
'as a file already exists at the destination path '
118+
'and the content does not match.')
119+
logger.info(msg, os.fsdecode(source), os.fsdecode(dest),
120+
type='misc', subtype='copy_overwrite')
121+
105122
shutil.copyfile(source, dest)
106123
with contextlib.suppress(OSError):
107124
# don't do full copystat because the source may be read-only
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import os
2+
import sys
3+
sys.path.insert(0, os.path.abspath('.'))
4+
5+
extensions = ['myext']
6+
html_static_path = ['user_static']
7+
html_theme = 'basic'

tests/roots/test-util-copyasset_overwrite/index.rst

Whitespace-only changes.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from pathlib import Path
2+
3+
from sphinx.util.fileutil import copy_asset
4+
5+
6+
def _copy_asset_overwrite_hook(app):
7+
css = app.outdir / '_static' / 'custom-styles.css'
8+
# html_static_path is copied by default
9+
assert css.read_text() == '/* html_static_path */\n'
10+
# warning generated by here
11+
copy_asset(
12+
Path(__file__).parent.joinpath('myext_static', 'custom-styles.css'),
13+
app.outdir / '_static',
14+
)
15+
# This demonstrates the overwriting
16+
assert css.read_text() == '/* extension styles */\n'
17+
return []
18+
19+
20+
def setup(app):
21+
app.connect('html-collect-pages', _copy_asset_overwrite_hook)
22+
app.add_css_file('custom-styles.css')
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/* extension styles */
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/* html_static_path */

tests/test_util/test_util_fileutil.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
from unittest import mock
44

5+
import pytest
6+
57
from sphinx.jinja2glue import BuiltinTemplateLoader
8+
from sphinx.util import strip_colors
69
from sphinx.util.fileutil import _template_basename, copy_asset, copy_asset_file
710

811

@@ -103,6 +106,20 @@ def excluded(path):
103106
assert not (destdir / '_templates' / 'sidebar.html').exists()
104107

105108

109+
@pytest.mark.xfail(reason='Filesystem chicanery(?)')
110+
@pytest.mark.sphinx('html', testroot='util-copyasset_overwrite')
111+
def test_copy_asset_overwrite(app):
112+
app.build()
113+
warnings = strip_colors(app.warning.getvalue())
114+
src = app.srcdir / 'myext_static' / 'custom-styles.css'
115+
dst = app.outdir / '_static' / 'custom-styles.css'
116+
assert warnings == (
117+
f'WARNING: Copying the source path {src} to {dst} will overwrite data, '
118+
'as a file already exists at the destination path '
119+
'and the content does not match.\n'
120+
)
121+
122+
106123
def test_template_basename():
107124
assert _template_basename('asset.txt') is None
108125
assert _template_basename('asset.txt.jinja') == 'asset.txt'

0 commit comments

Comments
 (0)