mirror of
https://github.com/zensical/zensical.git
synced 2026-10-08 13:51:22 +00:00
fix: handle symlinked files in watched dependencies (#1010)
Signed-off-by: Timothée Mazzucotelli <dev@pawamoy.fr>
This commit is contained in:
4 files changed
+193
-16
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user