Repository: sphinx-doc/sphinx

Issue:
Better crash messages when a file isn't found because it was removed by the user during the run
Hello,

Thank you for working on Sphinx, I used this lib heavily for years. 

I removed a file during the sphinx run and got the following crash:

```python
Traceback (most recent call last):

  File "/home/pierre/pylint/venv/lib/python3.10/site-packages/sphinx/cmd/build.py", line 276, in build_main

    app.build(args.force_all, filenames)

  File "/home/pierre/pylint/venv/lib/python3.10/site-packages/sphinx/application.py", line 329, in build

    self.builder.build_update()

  File "/home/pierre/pylint/venv/lib/python3.10/site-packages/sphinx/builders/__init__.py", line 288, in build_update

    self.build(to_build,

  File "/home/pierre/pylint/venv/lib/python3.10/site-packages/sphinx/builders/__init__.py", line 352, in build

    self.write(docnames, list(updated_docnames), method)

  File "/home/pierre/pylint/venv/lib/python3.10/site-packages/sphinx/builders/__init__.py", line 544, in write

    self._write_serial(sorted(docnames))

  File "/home/pierre/pylint/venv/lib/python3.10/site-packages/sphinx/builders/__init__.py", line 554, in _write_serial

    self.write_doc(docname, doctree)

  File "/home/pierre/pylint/venv/lib/python3.10/site-packages/sphinx/builders/html/__init__.py", line 649, in write_doc

    self.handle_page(docname, ctx, event_arg=doctree)

  File "/home/pierre/pylint/venv/lib/python3.10/site-packages/sphinx/builders/html/__init__.py", line 1098, in handle_page

    copyfile(self.env.doc2path(pagename), source_name)

  File "/home/pierre/pylint/venv/lib/python3.10/site-packages/sphinx/util/osutil.py", line 87, in copyfile

    if not path.exists(dest) or not filecmp.cmp(source, dest):

  File "/usr/lib/python3.10/filecmp.py", line 53, in cmp

    s1 = _sig(os.stat(f1))

FileNotFoundError: [Errno 2] No such file or directory: '/home/pierre/pylint/doc/user_guide/messages/warning/missing-pylint-confidence.rst'



Exception occurred:

  File "/usr/lib/python3.10/filecmp.py", line 53, in cmp

    s1 = _sig(os.stat(f1))

FileNotFoundError: [Errno 2] No such file or directory: '/home/pierre/pylint/doc/user_guide/messages/warning/missing-pylint-confidence.rst'

The full traceback has been saved in /tmp/sphinx-err-5ytmoq86.log, if you want to report the issue to the developers.

Please also report this if it was a user error, so that a better error message can be provided next time.

A bug report can be filed in the tracker at <https://github.com/sphinx-doc/sphinx/issues>. Thanks!
```

**Describe the solution you'd like**

Better error message without crash.

**Describe alternatives you've considered**

Not doing anything is reasonable too.



Discussion:

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 4549b9f682f..69845d33e74 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -131,6 +131,9 @@ Bugs fixed
   may be used,
   when multiple suffixes are specified in :confval:`source_suffix`.
   Patch by Sutou Kouhei.
+* #10786: improve the error message when a file to be copied (e.g., an asset)
+  is removed during Sphinx execution.
+  Patch by Bénédikt Tran.
 
 Testing
 -------
diff --git a/sphinx/util/osutil.py b/sphinx/util/osutil.py
index 93fc625280b..97a298ed05f 100644
--- a/sphinx/util/osutil.py
+++ b/sphinx/util/osutil.py
@@ -11,13 +11,14 @@
 import unicodedata
 from io import StringIO
 from os import path
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING
 
 from sphinx.deprecation import _deprecation_warning
 
 if TYPE_CHECKING:
     from collections.abc import Iterator
     from types import TracebackType
+    from typing import Any
 
 # SEP separates path elements in the canonical file names
 #
@@ -89,8 +90,16 @@ def copytimes(source: str | os.PathLike[str], dest: str | os.PathLike[str]) -> N
 def copyfile(source: str | os.PathLike[str], dest: str | os.PathLike[str]) -> None:
     """Copy a file and its modification times, if possible.
 
-    Note: ``copyfile`` skips copying if the file has not been changed
+    :param source: An existing source to copy.
+    :param dest: The destination path.
+    :raise FileNotFoundError: The *source* does not exist.
+
+    .. note:: :func:`copyfile` is a no-op if *source* and *dest* are identical.
     """
+    if not path.exists(source):
+        msg = f'{os.fsdecode(source)} does not exist'
+        raise FileNotFoundError(msg)
+
     if not path.exists(dest) or not filecmp.cmp(source, dest):
         shutil.copyfile(source, dest)
         with contextlib.suppress(OSError):


Test patch:
diff --git a/tests/test_builders/test_build_html.py b/tests/test_builders/test_build_html.py
index 0d88645d972..9a3b0c8bf00 100644
--- a/tests/test_builders/test_build_html.py
+++ b/tests/test_builders/test_build_html.py
@@ -1,5 +1,6 @@
 """Test the HTML builder and check output against XPath."""
 
+import os
 import posixpath
 import re
 
@@ -8,6 +9,7 @@
 from sphinx.builders.html import validate_html_extra_path, validate_html_static_path
 from sphinx.deprecation import RemovedInSphinx80Warning
 from sphinx.errors import ConfigError
+from sphinx.util.console import strip_colors
 from sphinx.util.inventory import InventoryFile
 
 FIGURE_CAPTION = ".//figure/figcaption/p"
@@ -387,3 +389,25 @@ def test_html_signaturereturn_icon(app):
     content = (app.outdir / 'index.html').read_text(encoding='utf8')
 
     assert ('<span class="sig-return-icon">&#x2192;</span>' in content)
+
+
+@pytest.mark.sphinx('html', testroot='root', srcdir=os.urandom(4).hex())
+def test_html_remove_sources_before_write_gh_issue_10786(app, warning):
+    # see:  https://github.com/sphinx-doc/sphinx/issues/10786
+    target = app.srcdir / 'img.png'
+
+    def handler(app):
+        assert target.exists()
+        target.unlink()
+        return []
+
+    app.connect('html-collect-pages', handler)
+    assert target.exists()
+    app.build()
+    assert not target.exists()
+
+    ws = strip_colors(warning.getvalue()).splitlines()
+    assert len(ws) >= 1
+
+    file = os.fsdecode(target)
+    assert f'WARNING: cannot copy image file {file!r}: {file!s} does not exist' == ws[-1]