feature(1100): Discover .markdown posts and always log run summary
Extend the anniversary matcher to recursively scan both .md and .markdown posts under _posts/blog, retaining the existing filename validation and relative-path dedupe identifier so published-state bookkeeping keeps working. Drop the zero-counter short-circuit in log_run_summary so the run_complete line is always emitted on a successful pass. Debug and manual runs now clearly show that scanning completed and found nothing instead of going silent. Update matching, logging, and run-logging tests to cover the new extension and always-emit contract, and refresh the logging architecture doc accordingly.
This commit is contained in:
@@ -56,7 +56,7 @@ Any matching key is replaced with the string `[REDACTED]`.
|
||||
| `configure_json_logging(level=INFO)` | First line of `main.main()`. Idempotent — clears existing handlers. | n/a | n/a |
|
||||
| `log_startup(**fields)` | After config load + `data_dir.mkdir`, before the pipeline pass. | `INFO` | `startup` |
|
||||
| `log_error(event, *, exc=None, **fields)` | When a pipeline attempt raises, or when the retry budget is exhausted. | `ERROR` | the supplied event name; `exc_type` added when `exc` is provided. |
|
||||
| `log_run_summary(scanned, matched, posted, skipped, posted_ids)` | Once per successful pass. **Silently skipped** when all counters are zero and no ids were posted. | `INFO` | `run_complete` |
|
||||
| `log_run_summary(scanned, matched, posted, skipped, posted_ids)` | Once per successful pass. Always emitted (including zero-counter passes) so debug and manual runs clearly show the scan completed. | `INFO` | `run_complete` |
|
||||
|
||||
## Startup payload
|
||||
|
||||
@@ -87,8 +87,9 @@ Each is added to the `startup` event payload under its own key.
|
||||
}
|
||||
```
|
||||
|
||||
A pass with no candidates emits **no line at all**, matching the
|
||||
"silent on no matches" contract.
|
||||
A pass with no candidates still emits a single `run_complete`
|
||||
summary line with all counters set to zero, so operators can confirm
|
||||
the scan ran and found nothing to publish.
|
||||
|
||||
# Examples
|
||||
|
||||
|
||||
@@ -113,11 +113,9 @@ def log_run_summary(
|
||||
) -> None:
|
||||
"""Emit a single structured info line on successful completion.
|
||||
|
||||
A run with no matching posts is silent — no log line is emitted.
|
||||
Always emitted (even with zero counters) so debug and manual runs
|
||||
clearly show that scanning completed and found nothing.
|
||||
"""
|
||||
if scanned == 0 and matched == 0 and posted == 0 and skipped == 0 and not posted_ids:
|
||||
return
|
||||
|
||||
fields = _redact_dict(
|
||||
{
|
||||
"event": "run_complete",
|
||||
|
||||
@@ -16,6 +16,8 @@ _LOGGER = logging.getLogger("tenbackward.matching")
|
||||
|
||||
_FILENAME_PATTERN = re.compile(r"^(\d{4})-(\d{2})-(\d{2})-(.+)$")
|
||||
|
||||
_POST_SUFFIXES = (".md", ".markdown")
|
||||
|
||||
_NON_ALNUM_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
_DEFAULT_TZ = "Europe/Berlin"
|
||||
@@ -194,7 +196,16 @@ def find_anniversary_matches(
|
||||
return []
|
||||
|
||||
matches: list[MatchedPost] = []
|
||||
for md_path in sorted(post_root.rglob("*.md")):
|
||||
seen: set[Path] = set()
|
||||
candidates: list[Path] = []
|
||||
for suffix in _POST_SUFFIXES:
|
||||
for md_path in sorted(post_root.rglob(f"*{suffix}")):
|
||||
if md_path in seen:
|
||||
continue
|
||||
seen.add(md_path)
|
||||
candidates.append(md_path)
|
||||
candidates.sort()
|
||||
for md_path in candidates:
|
||||
if not md_path.is_file():
|
||||
continue
|
||||
try:
|
||||
|
||||
@@ -61,10 +61,19 @@ def test_log_run_summary_emits_one_line_with_counters() -> None:
|
||||
assert payload["posted_ids"] == ["x"]
|
||||
|
||||
|
||||
def test_log_run_summary_silent_when_no_matches() -> None:
|
||||
def test_log_run_summary_always_emits_summary_line_with_zero_counters() -> None:
|
||||
buf = _capture(logging.getLogger("tenbackward"))
|
||||
log_run_summary(0, 0, 0, 0, [])
|
||||
assert buf.getvalue() == ""
|
||||
lines = [line for line in buf.getvalue().splitlines() if line.strip()]
|
||||
assert len(lines) == 1
|
||||
payload = json.loads(lines[0])
|
||||
assert payload["message"] == "run complete"
|
||||
assert payload["event"] == "run_complete"
|
||||
assert payload["scanned"] == 0
|
||||
assert payload["matched"] == 0
|
||||
assert payload["posted"] == 0
|
||||
assert payload["skipped"] == 0
|
||||
assert payload["posted_ids"] == []
|
||||
|
||||
|
||||
def test_log_startup_emits_single_info_line() -> None:
|
||||
|
||||
@@ -222,6 +222,48 @@ def test_missing_post_root_returns_empty(tmp_path: Path) -> None:
|
||||
assert find_anniversary_matches(tmp_path / "does-not-exist", SITE_URL, today=today) == []
|
||||
|
||||
|
||||
def test_markdown_extension_is_discovered(tmp_path: Path) -> None:
|
||||
root = tmp_path / "_posts" / "blog"
|
||||
_write_post(
|
||||
root,
|
||||
"2014/2014-08-04-foo.markdown",
|
||||
body="---\ntitle: Foo\ndate: 2014-08-04\n---\nbody\n",
|
||||
)
|
||||
|
||||
today = date(2024, 8, 4)
|
||||
matches = find_anniversary_matches(root, SITE_URL, today=today)
|
||||
|
||||
assert len(matches) == 1
|
||||
match = matches[0]
|
||||
assert match.path == "2014/2014-08-04-foo.markdown"
|
||||
assert match.title == "Foo"
|
||||
assert match.date == date(2014, 8, 4)
|
||||
assert match.url == "https://chaospott.de/2014/08/04/foo/"
|
||||
|
||||
|
||||
def test_markdown_and_md_are_discovered_together(tmp_path: Path) -> None:
|
||||
root = tmp_path / "_posts" / "blog"
|
||||
_write_post(
|
||||
root,
|
||||
"2014/2014-08-04-from-md.md",
|
||||
body="---\ntitle: Md\ndate: 2014-08-04\n---\n",
|
||||
)
|
||||
_write_post(
|
||||
root,
|
||||
"2014/2014-08-04-from-markdown.markdown",
|
||||
body="---\ntitle: Markdown\ndate: 2014-08-04\n---\n",
|
||||
)
|
||||
|
||||
today = date(2024, 8, 4)
|
||||
matches = find_anniversary_matches(root, SITE_URL, today=today)
|
||||
|
||||
paths = {m.path for m in matches}
|
||||
assert paths == {
|
||||
"2014/2014-08-04-from-md.md",
|
||||
"2014/2014-08-04-from-markdown.markdown",
|
||||
}
|
||||
|
||||
|
||||
def test_invalid_filename_is_skipped(tmp_path: Path) -> None:
|
||||
root = tmp_path / "_posts" / "blog"
|
||||
_write_post(root, "2014/not-a-date.md", body="---\ntitle: Bad\n---\n")
|
||||
|
||||
@@ -80,7 +80,7 @@ def test_run_emits_one_info_summary_on_success(env_setup, data_dir, capture_logg
|
||||
assert "already-1" in state
|
||||
|
||||
|
||||
def test_run_silent_when_no_matches(env_setup, data_dir, capture_logger, monkeypatch) -> None:
|
||||
def test_run_emits_zero_summary_when_no_matches(env_setup, data_dir, capture_logger, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DATA_DIR", str(data_dir))
|
||||
monkeypatch.setattr(main_module, "ensure_repo", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(main_module, "_iter_candidates", lambda config: [])
|
||||
@@ -90,7 +90,13 @@ def test_run_silent_when_no_matches(env_setup, data_dir, capture_logger, monkeyp
|
||||
|
||||
lines = _run_lines(capture_logger)
|
||||
summary = [line for line in lines if line.get("event") == "run_complete"]
|
||||
assert summary == []
|
||||
assert len(summary) == 1
|
||||
payload = summary[0]
|
||||
assert payload["scanned"] == 0
|
||||
assert payload["matched"] == 0
|
||||
assert payload["posted"] == 0
|
||||
assert payload["skipped"] == 0
|
||||
assert payload["posted_ids"] == []
|
||||
|
||||
|
||||
def test_run_returns_nonzero_on_pipeline_error(env_setup, data_dir, capture_logger, monkeypatch) -> None:
|
||||
|
||||
Reference in New Issue
Block a user