Skip to content

Commit

Permalink
fix[alt-support]: support records with multiple source_filenames
Browse files Browse the repository at this point in the history
  • Loading branch information
dairiki committed Jan 23, 2024
1 parent fdd7f97 commit 85d9731
Show file tree
Hide file tree
Showing 5 changed files with 137 additions and 50 deletions.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,13 @@ follow file renames.
Currently, if unspecified, `follow_renames` defaults to _true_.
(This may change in the future.)

> **Note** Since we currently run `git log` on a per-source-file basis, when `--follow`
> is specified, _copied_ files will be detected as “renamed”. This may not be ideal.
> **Note** Since we currently run `git log` on a per-record basis, when `--follow`
> is specified, _copied_ files may be detected as “renamed”. This may not be ideal.
> **Note** `follow_renames` is not supported with Lektor’s
> [alternatives][alts] feature is enabled. (When _alts_ are in use,
> each record has multiple source files. `Git log` does not support
> the `--follow` option when multiple source files are specified.)
#### `follow_rename_threshold`

Expand All @@ -103,6 +107,7 @@ detection to exact renames only. The default value is 50.
[git-log-M]: https://git-scm.com/docs/git-log#Documentation/git-log.txt--Mltngt
[git-log-follow]: https://git-scm.com/docs/git-log#Documentation/git-log.txt---follow
[configuration file]: https://www.getlektor.com/docs/plugins/howto/#configure-plugins
[alts]: https://www.getlektor.com/docs/content/alts/

## Examples

Expand Down
61 changes: 39 additions & 22 deletions lektor_git_timestamp.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import re
import subprocess
import sys
import warnings
from contextlib import suppress
from dataclasses import dataclass
from typing import Any
Expand All @@ -23,7 +24,6 @@
from lektor.context import get_ctx
from lektor.pluginsystem import get_plugin
from lektor.pluginsystem import Plugin
from lektor.reporter import reporter
from lektor.sourceobj import VirtualSourceObject
from lektor.types import DateTimeType
from lektor.utils import bool_from_string
Expand Down Expand Up @@ -51,15 +51,15 @@ def run_git(*args: str | StrPath) -> str:
return proc.stdout


def _fs_mtime(filename: StrPath) -> int | None:
try:
st = os.stat(filename)
except OSError as exc:
reporter.report_generic(f"{filename}: {exc!s}")
def _fs_mtime(filenames: Iterable[StrPath]) -> int | None:
mtimes = []
for filename in filenames:
with suppress(OSError):
mtimes.append(os.stat(filename).st_mtime)
if len(mtimes) == 0:
return None
else:
# (truncate to one second resolution)
return int(st.st_mtime)
# (truncate to one second resolution)
return int(max(mtimes))


def _is_dirty(filename: StrPath) -> bool:
Expand All @@ -73,29 +73,40 @@ class Timestamp(NamedTuple):


def _iter_timestamps(
filename: StrPath, config: Mapping[str, str]
filenames: Sequence[StrPath], config: Mapping[str, str]
) -> Iterator[Timestamp]:
options = ["--remove-empty"]
follow_renames = bool_from_string(config.get("follow_renames", "true"))
if follow_renames:
options.append("--follow")
with suppress(LookupError, ValueError):
threshold = float(config["follow_rename_threshold"])
if 0 < threshold < 100:
options.append(f"-M{threshold:.4f}%")
if len(filenames) > 1:
warnings.warn(
"The follow_renames option is not supported when records have"
" multiple source files (e.g. when alts are in use).",
stacklevel=2,
)
else:
options.append("--follow")
with suppress(LookupError, ValueError):
threshold = float(config["follow_rename_threshold"])
if 0 < threshold < 100:
options.append(f"-M{threshold:.4f}%")

output = run_git(
"log",
"--pretty=format:%at %B",
"-z",
*options,
"--",
filename,
*filenames,
)
if not output or _is_dirty(filename):
ts = _fs_mtime(filename)
if ts is not None:
yield Timestamp(ts, None)

if not output:
ts = _fs_mtime(filenames)
else:
ts = _fs_mtime(filter(_is_dirty, filenames))
if ts is not None:
yield Timestamp(ts, None)

if output:
for line in output.split("\0"):
tstamp, _, commit_message = line.partition(" ")
Expand Down Expand Up @@ -163,8 +174,14 @@ def timestamps(self) -> tuple[Timestamp, ...]:
plugin_config: Mapping[str, str] = {}
with suppress(LookupError):
plugin_config = get_plugin(GitTimestampPlugin, self.pad.env).get_config()

return tuple(_iter_timestamps(self.source_filename, plugin_config))
source_filenames = tuple(self.iter_source_filenames())
return tuple(_iter_timestamps(source_filenames, plugin_config))

def iter_source_filenames(self) -> Iterator[StrPath]:
# Compatibility: The default implementation of
# VirtualSourceObject.iter_source_filenames in Lektor < 3.4
# returns only the primary source filename.
return self.record.iter_source_filenames() # type: ignore[no-any-return]


@dataclass
Expand Down
17 changes: 10 additions & 7 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def modify(

def commit(
self,
filename: StrPath,
filename_: StrPath | tuple[StrPath, ...],
ts: int | datetime.datetime | None = None,
message: str = "test",
data: str | None = None,
Expand All @@ -109,13 +109,16 @@ def commit(
env = os.environ.copy()
env["GIT_AUTHOR_DATE"] = dt.isoformat("T")

if data is not None:
file_path = self.work_tree / filename
file_path.write_text(data)
else:
self.modify(filename)
filenames = filename_ if isinstance(filename_, tuple) else (filename_,)
for filename in filenames:
if data is not None:
file_path = self.work_tree / filename
file_path.write_text(data)
else:
self.modify(filename)

self.run_git("add", os.fspath(filename))

self.run_git("add", os.fspath(filename))
self.run_git("commit", "--message", str(message), env=env)


Expand Down
50 changes: 49 additions & 1 deletion tests/test_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,31 @@
from lektor.environment import Environment


@pytest.fixture(params=(False, True))
def enable_alts(request: pytest.FixtureRequest) -> bool:
return request.param # type: ignore[no-any-return]


ALT_CONFIG = """
[alternatives.en]
name = English
primary = yes
[alternatives.de]
name = German
url_prefix = /de/
locale = de
"""


@pytest.fixture
def project(tmp_path: Path) -> Project:
def project(tmp_path: Path, enable_alts: bool) -> Project:
site_src = Path(__file__).parent / "test-site"
site_path = tmp_path / "site"
shutil.copytree(site_src, site_path)
if enable_alts:
with Path(site_path, "Test Site.lektorproject").open("a") as fp:
fp.write(ALT_CONFIG)
return Project.from_path(site_path)


Expand Down Expand Up @@ -155,6 +175,34 @@ def test_last_mod_for_dirty_file(
assert pad.root["last_mod"] == now


def test_pub_date_de_file(
git_repo: DummyGitRepo,
pad: Pad,
pub_date: datetime.datetime,
now: datetime.datetime,
) -> None:
git_repo.commit(
("site/content/contents+de.lr", "site/content/contents.lr"), pub_date
)
git_repo.touch("site/content/contents.lr", now)
assert pad.root["pub_date"] == pub_date
assert jinja2.is_undefined(pad.root["last_mod"])


def test_lastmod_de_file(
git_repo: DummyGitRepo,
pad: Pad,
pub_date: datetime.datetime,
now: datetime.datetime,
) -> None:
git_repo.commit(
("site/content/contents+de.lr", "site/content/contents.lr"), pub_date
)
git_repo.modify("site/content/contents.lr", now)
assert pad.root["pub_date"] == pub_date
assert pad.root["last_mod"] == now


@pytest.mark.xfail(
Version(metadata.version("lektor")) < Version("3.3"),
reason="Lektor is too old to support sorting by descriptor-valued fields",
Expand Down
50 changes: 32 additions & 18 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,15 @@

import datetime
import os
import re
import subprocess
from typing import Iterator
from typing import Mapping
from typing import Sequence
from typing import TYPE_CHECKING

import jinja2
import pytest
from lektor.environment import PRIMARY_ALT
from lektor.reporter import BufferReporter
from lektor.types import RawValue

from conftest import DummyGitRepo
Expand Down Expand Up @@ -51,14 +50,10 @@ class Test__fs_mtime:
def test(self, git_repo: DummyGitRepo) -> None:
ts = 1589238180
git_repo.touch("test.txt", ts)
assert _fs_mtime("test.txt") == ts
assert _fs_mtime(["test.txt"]) == ts

def test_missing_file(self, git_repo: DummyGitRepo, env: Environment) -> None:
with BufferReporter(env) as reporter:
assert _fs_mtime("test.txt") is None
event, data = reporter.buffer[0]
assert event == "generic"
assert re.match(r"(?i)test.txt: .*\bno such file", data["message"])
assert _fs_mtime(["test.txt"]) is None


class Test__is_dirty:
Expand All @@ -81,13 +76,15 @@ def test_from_git(self, git_repo: DummyGitRepo) -> None:
plugin_config: dict[str, str] = {}
ts = 1589238186
git_repo.commit("test.txt", ts, "message")
assert list(_iter_timestamps("test.txt", plugin_config)) == [(ts, "message\n")]
assert list(_iter_timestamps(["test.txt"], plugin_config)) == [
(ts, "message\n")
]

def test_from_mtime(self, git_repo: DummyGitRepo) -> None:
plugin_config: dict[str, str] = {}
ts = 1589238186
git_repo.touch("test.txt", ts)
assert list(_iter_timestamps("test.txt", plugin_config)) == [(ts, None)]
assert list(_iter_timestamps(["test.txt"], plugin_config)) == [(ts, None)]

def test_from_mtime_and_git(self, git_repo: DummyGitRepo) -> None:
plugin_config: dict[str, str] = {}
Expand All @@ -96,7 +93,7 @@ def test_from_mtime_and_git(self, git_repo: DummyGitRepo) -> None:
git_repo.commit("test.txt", ts1, "commit")
git_repo.modify("test.txt")
git_repo.touch("test.txt", ts2)
assert list(_iter_timestamps("test.txt", plugin_config)) == [
assert list(_iter_timestamps(["test.txt"], plugin_config)) == [
(ts2, None),
(ts1, "commit\n"),
]
Expand Down Expand Up @@ -124,14 +121,25 @@ def test_follow(
git_repo.commit("name2.txt", ts2, "commit 2", data="content\n")
git_repo.commit("name3.txt", ts3, "commit 3", data="content\n")
assert (
list(_iter_timestamps("name3.txt", plugin_config))
list(_iter_timestamps(["name3.txt"], plugin_config))
== [
(ts3, "commit 3\n"),
(ts2, "commit 2\n"),
(ts1, "commit 1\n"),
][:expected_commits]
)

def test_from_git_two_files(self, git_repo: DummyGitRepo) -> None:
plugin_config: dict[str, str] = {}
ts1 = 1589238186
ts2 = 1589238198
git_repo.commit("test1.txt", ts1, "message1")
git_repo.commit("test2.txt", ts2, "message2")
assert list(_iter_timestamps(["test1.txt", "test2.txt"], plugin_config)) == [
(ts2, "message2\n"),
(ts1, "message1\n"),
]


class Test_get_mtime:
def test_not_in_git(self) -> None:
Expand Down Expand Up @@ -211,13 +219,19 @@ def test_missing_file(self, git_repo: DummyGitRepo) -> None:
class DummyPage:
alt = PRIMARY_ALT

def __init__(self, source_filename: str, path: str = "/", pad: Pad | None = None):
self.source_filename = source_filename
def __init__(
self, source_filenames: Sequence[str], path: str = "/", pad: Pad | None = None
):
self.source_filenames = source_filenames
self.path = path
self.pad = pad

@property
def source_filename(self) -> str | None:
return next(self.iter_source_filenames(), None)

def iter_source_filenames(self) -> Iterator[str]:
yield self.source_filename
return iter(self.source_filenames)


class TestGitTimestampSource:
Expand All @@ -229,7 +243,7 @@ def ts_now(self) -> int:
def record(self, git_repo: DummyGitRepo, ts_now: int, pad: Pad) -> Record:
git_repo.touch("test.txt", ts_now)
source_filename = os.path.abspath("test.txt")
return DummyPage(source_filename, path="/test", pad=pad)
return DummyPage([source_filename], path="/test", pad=pad)

@pytest.fixture
def src(self, record: Record) -> GitTimestampSource:
Expand Down Expand Up @@ -269,7 +283,7 @@ def desc(self) -> GitTimestampDescriptor:
@pytest.fixture
def record(self, git_repo: DummyGitRepo, pad: Pad) -> Record:
source_filename = os.path.abspath("test.txt")
return DummyPage(source_filename, pad=pad)
return DummyPage([source_filename], pad=pad)

def test_class_descriptor(self, desc: GitTimestampDescriptor) -> None:
assert desc.__get__(None) is desc
Expand Down Expand Up @@ -320,7 +334,7 @@ def plugin(self, env: Environment) -> GitTimestampPlugin:
@pytest.fixture
def record(self, git_repo: DummyGitRepo, pad: Pad) -> Record:
source_filename = os.path.abspath("test.txt")
return DummyPage(source_filename, pad=pad)
return DummyPage([source_filename], pad=pad)

def test_on_setup_env(self, plugin: GitTimestampPlugin, env: Environment) -> None:
plugin.on_setup_env()
Expand Down

0 comments on commit 85d9731

Please sign in to comment.