fix: handle symlinked files in watched dependencies (#1010)

Signed-off-by: Timothée Mazzucotelli <dev@pawamoy.fr>
This commit is contained in:
Timothée Mazzucotelli authored and GitHub committed 2026-10-06 20:07:50 +02:00
1 parent 8e96dd1038
commit 4924feab39
4 files changed
+193 -16

No files matched your search

+28 -16
View File
@@ -158,7 +158,8 @@ impl Watcher {
}
sources.push(SourceMount::new(path, String::from(".")));
// Track seen files to restart on config or template change
// Original paths are tracked so discovery of a symlink and its target
// is not mistaken for a change to an already discovered file.
let mut seen = BTreeSet::new();
// Normalize watched paths once, so path comparisons stay stable across
@@ -235,9 +236,7 @@ impl Watcher {
}
// Check if the config file reloaded, and terminate agent,
// as we need to kick off the entire pipeline again
if event_path == config_path
&& !seen.insert(config_path.clone())
{
if event_path == config_path && !seen.insert(event.path()) {
return Err(Error::Disconnected);
}
@@ -245,7 +244,7 @@ impl Watcher {
// and restart the build if we've already seen the file
for dir in &theme_dirs {
if event_path.starts_with(dir)
&& !seen.insert(event_path.clone())
&& !seen.insert(event.path())
{
return Err(Error::Disconnected);
}
@@ -254,7 +253,7 @@ impl Watcher {
// Check if one of the source files managed by mkdocstrings
// changed, and restart the build
if watched_files.contains(&event_path)
&& !seen.insert(event_path.clone())
&& !seen.insert(event.path())
{
return Err(Error::Disconnected);
}
@@ -298,11 +297,19 @@ impl Watcher {
continue;
}
// Compute an identifier from the path and known contexts.
// Rebuilds can be triggered by external watched dependencies.
// Only paths with a source mount are admitted to the workflow.
let mounted = |path: &Path| {
sources
.iter()
.any(|source| path.starts_with(&source.root))
};
match event {
// File was created or modified
Event::Create { path, .. }
| Event::Modify { path, .. } => {
| Event::Modify { path, .. }
if mounted(&path) =>
{
batch.push(Change::Insert(
to_id(&path, &sources)?.into(),
Source::from(path),
@@ -311,21 +318,26 @@ impl Watcher {
// File was renamed
Event::Rename { from, to, .. } => {
batch.push(Change::Remove(
to_id(&from, &sources)?.into(),
));
batch.push(Change::Insert(
to_id(&to, &sources)?.into(),
Source::from(to),
));
if mounted(&from) {
batch.push(Change::Remove(
to_id(&from, &sources)?.into(),
));
}
if mounted(&to) {
batch.push(Change::Insert(
to_id(&to, &sources)?.into(),
Source::from(to),
));
}
}
// File was removed
Event::Remove { path, .. } => {
Event::Remove { path, .. } if mounted(&path) => {
batch.push(Change::Remove(
to_id(&path, &sources)?.into(),
));
}
_ => {}
}
}
+67
View File
@@ -158,6 +158,73 @@ def test_build_with_config_in_watch(
assert (tmp_path / "site" / "index.html").is_file()
@pytest.mark.parametrize("toml", [False, True], ids=["yaml", "toml"])
@pytest.mark.parametrize(
"watch_path", ["extras/page.md", "extras", "docs/index.md", "."]
)
@pytest.mark.parametrize("external_target", [False, True])
def test_build_with_symlinked_page_in_watch(
tmp_path: Path, toml: bool, watch_path: str, external_target: bool
) -> None:
project = tmp_path / "project"
project.mkdir()
if external_target and watch_path.startswith("extras"):
watch_path = "../" + watch_path
config = (
_make_toml_project(project, toml_extra=f'watch = ["{watch_path}"]')
if toml
else _make_yml_project(project, yml_extra=f'watch: ["{watch_path}"]')
)
extras = (tmp_path if external_target else project) / "extras"
extras.mkdir()
target = extras / "page.md"
target.write_text("# Symlinked page\n", encoding="utf-8")
# The watched target is exposed through two independent docs paths.
(project / "docs" / "index.md").unlink()
for name in ["index.md", "alias.md"]:
try:
(project / "docs" / name).symlink_to(target)
except OSError as error:
pytest.skip(f"symbolic links unavailable: {error}")
_build(config)
for output in ["index.html", "alias/index.html"]:
html = (project / "site" / output).read_text(encoding="utf-8")
assert "Symlinked page" in html
@pytest.mark.parametrize("toml", [False, True], ids=["yaml", "toml"])
@pytest.mark.parametrize("dependencies", ["macros", "watch", "both"])
def test_build_ignores_dangling_symlinks_in_watched_directories(
tmp_path: Path, toml: bool, dependencies: str
) -> None:
if toml:
options = 'watch = ["docs"]\n' if dependencies != "macros" else ""
if dependencies != "watch":
options += "[project.plugins.macros]\n"
config = _make_toml_project(tmp_path, toml_extra=options)
else:
options = "watch: [docs]\n" if dependencies != "macros" else ""
if dependencies != "watch":
options += "plugins: [macros]\n"
config = _make_yml_project(tmp_path, yml_extra=options)
# A dangling link is ignored while the existing page is still rendered.
try:
(tmp_path / "docs" / "broken.md").symlink_to("missing.md")
except OSError as error:
pytest.skip(f"symbolic links unavailable: {error}")
_build(config)
html = (tmp_path / "site" / "index.html").read_text(encoding="utf-8")
assert "Hello" in html
assert not (tmp_path / "site" / "broken").exists()
@pytest.mark.parametrize("config_format", ["yaml", "toml"])
@pytest.mark.parametrize(
("enabled", "version_selector", "expected"),
+94
View File
@@ -29,6 +29,7 @@ import json
import os
import subprocess
import sys
import time
from textwrap import dedent
from typing import TYPE_CHECKING
from urllib.parse import parse_qs, urlsplit
@@ -74,6 +75,99 @@ def _write_config(
return config
@pytest.mark.parametrize("toml", [False, True], ids=["yaml", "toml"])
@pytest.mark.parametrize("target_location", ["docs", "project", "external"])
def test_default_include_directory_builds_symlinked_pages(
tmp_path: Path, toml: bool, target_location: str
) -> None:
project = tmp_path / "project"
project.mkdir()
config = _write_config(project, {"on_error_fail": True}, toml=toml)
docs = project / "content"
target_dir = {"docs": docs, "project": project, "external": tmp_path}
target = target_dir[target_location] / "page.md"
target.write_text("# Hello\n\nValue: {{ 1 + 1 }}\n", encoding="utf-8")
# Two aliases are discovered alongside the target without ending the build.
for name in ["index.md", "alias.md"]:
try:
(docs / name).symlink_to(target)
except OSError as error:
pytest.skip(f"symbolic links unavailable: {error}")
zensical.build(str(config), {"clean": True, "strict": False})
for output in ["index.html", "alias/index.html"]:
html = (project / "site" / output).read_text(encoding="utf-8")
assert "Value: 2" in html
@pytest.mark.parametrize("dependency", ["page", "include"])
def test_serve_rebuilds_after_symlinked_macro_dependency_changes(
tmp_path: Path, dependency: str
) -> None:
config = _write_config(tmp_path, {"on_error_fail": True})
with config.open("a", encoding="utf-8") as stream:
stream.write("dev_addr: 127.0.0.1:0\n")
docs = tmp_path / "content"
target = tmp_path / ("page.md" if dependency == "page" else "fragment.j2")
target.write_text("First value: {{ 1 + 1 }}\n", encoding="utf-8")
alias = docs / ("index.md" if dependency == "page" else "fragment.j2")
try:
alias.symlink_to(target)
except OSError as error:
pytest.skip(f"symbolic links unavailable: {error}")
if dependency == "include":
(docs / "index.md").write_text(
'{% include "fragment.j2" %}\n', encoding="utf-8"
)
output = tmp_path / "site" / "index.html"
with (tmp_path / "serve.log").open("w+", encoding="utf-8") as log:
process = subprocess.Popen( # noqa: S603
[sys.executable, "-m", "zensical", "serve", "-f", str(config)],
cwd=tmp_path,
stdout=log,
stderr=subprocess.STDOUT,
)
def wait_for(text: str) -> None:
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
try:
content = output.read_text(encoding="utf-8")
except FileNotFoundError:
content = ""
if text in content:
return
if process.poll() is not None:
break
time.sleep(0.02)
log.seek(0)
pytest.fail(f"serve did not rebuild: {log.read()}")
try:
wait_for("First value: 2")
# Canonical targets are still watched after alias discovery.
target.write_text("Updated value: {{ 1 + 1 }}\n", encoding="utf-8")
timestamp = target.stat().st_mtime + 2
os.utime(target, (timestamp, timestamp))
wait_for("Updated value: 2")
assert process.poll() is None
finally:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
@pytest.mark.parametrize("toml", [False, True], ids=["yaml", "toml"])
def test_force_render_paths_uses_docs_paths_and_page_metadata(
tmp_path: Path, toml: bool
+4
View File
@@ -1320,6 +1320,8 @@ def _list_macros_files(config: dict, config_file: str) -> set[tuple[str, int]]:
for root, _, files in os.walk(candidate_dir):
for file in files:
file_path = os.path.join(root, file)
if not os.path.isfile(file_path):
continue
mtime = int(os.path.getmtime(file_path))
files_with_mtime.add((file_path, mtime))
@@ -1353,6 +1355,8 @@ def _list_watch_files(config: dict, config_file: str) -> set[tuple[str, int]]:
for dirpath, _, files in os.walk(path):
for file in files:
file_path = os.path.join(dirpath, file)
if not os.path.isfile(file_path):
continue
mtime = int(os.path.getmtime(file_path))
files_with_mtime.add((file_path, mtime))
return files_with_mtime