AI Implementation feature(1083): Environment Configuration and Logging Baseline (#2)
This commit was merged in pull request #2.
This commit is contained in:
@@ -16,9 +16,13 @@ def env_setup(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""
|
||||
monkeypatch.setenv("MASTODON_BASE_URL", "https://mastodon.example")
|
||||
monkeypatch.setenv("MASTODON_ACCESS_TOKEN", "test-token")
|
||||
monkeypatch.setenv("VISIBILITY", "public")
|
||||
monkeypatch.setenv("SITE_URL", "https://blog.example.com")
|
||||
monkeypatch.setenv("HASHTAGS", "#throwback,#10backward")
|
||||
monkeypatch.setenv("THROWNBACK_PREFIX", "Throwback:")
|
||||
monkeypatch.setenv("MAX_RETRIES", "3")
|
||||
monkeypatch.setenv("RUN_AT", "09:00")
|
||||
monkeypatch.setenv("TZ", "Europe/Berlin")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
+96
-18
@@ -9,15 +9,30 @@ from tenbackward.config import ConfigError, load_config, validate_config
|
||||
|
||||
|
||||
def _populate(env_setup) -> dict[str, str]:
|
||||
return {k: os.environ[k] for k in os.environ if k.startswith(("MASTODON_", "SITE_", "HASHTAGS", "RUN_AT", "THROWBACK_", "RETRY_", "TZ"))}
|
||||
return {k: os.environ[k] for k in os.environ if k.startswith(("MASTODON_", "VISIBILITY", "SITE_", "HASHTAGS", "THROWNBACK_", "MAX_RETRIES", "RUN_AT", "TZ"))}
|
||||
|
||||
|
||||
def _full_values() -> dict[str, str]:
|
||||
return {
|
||||
"MASTODON_BASE_URL": "https://mastodon.example",
|
||||
"MASTODON_ACCESS_TOKEN": "x",
|
||||
"VISIBILITY": "public",
|
||||
"SITE_URL": "https://blog.example.com",
|
||||
"HASHTAGS": "#throwback",
|
||||
"THROWNBACK_PREFIX": "Throwback:",
|
||||
"MAX_RETRIES": "3",
|
||||
"RUN_AT": "09:00",
|
||||
"TZ": "Europe/Berlin",
|
||||
}
|
||||
|
||||
|
||||
def test_load_config_succeeds_with_complete_env(env_setup) -> None:
|
||||
config = load_config()
|
||||
assert config.mastodon_base_url == "https://mastodon.example"
|
||||
assert config.run_at == "09:00"
|
||||
assert config.retry_count == 3
|
||||
assert config.max_retries == 3
|
||||
assert config.throwback_prefix == "Throwback:"
|
||||
assert config.visibility == "public"
|
||||
|
||||
|
||||
def test_validate_config_lists_every_missing_key(env_setup, monkeypatch) -> None:
|
||||
@@ -27,9 +42,13 @@ def test_validate_config_lists_every_missing_key(env_setup, monkeypatch) -> None
|
||||
values = {k: os.environ.get(k, "") for k in [
|
||||
"MASTODON_BASE_URL",
|
||||
"MASTODON_ACCESS_TOKEN",
|
||||
"VISIBILITY",
|
||||
"SITE_URL",
|
||||
"HASHTAGS",
|
||||
"THROWNBACK_PREFIX",
|
||||
"MAX_RETRIES",
|
||||
"RUN_AT",
|
||||
"TZ",
|
||||
]}
|
||||
|
||||
with pytest.raises(ConfigError) as excinfo:
|
||||
@@ -41,30 +60,63 @@ def test_validate_config_lists_every_missing_key(env_setup, monkeypatch) -> None
|
||||
|
||||
|
||||
def test_validate_config_rejects_bad_run_at() -> None:
|
||||
values = {
|
||||
"MASTODON_BASE_URL": "https://mastodon.example",
|
||||
"MASTODON_ACCESS_TOKEN": "x",
|
||||
"SITE_URL": "https://blog.example.com",
|
||||
"HASHTAGS": "#x",
|
||||
"RUN_AT": "25:99",
|
||||
"MASTODON_VISIBILITY": "public",
|
||||
"THROWBACK_PREFIX": "Throwback:",
|
||||
"RETRY_COUNT": "3",
|
||||
"TZ": "Europe/Berlin",
|
||||
}
|
||||
values = _full_values()
|
||||
values["RUN_AT"] = "25:99"
|
||||
with pytest.raises(ConfigError):
|
||||
validate_config(values)
|
||||
|
||||
|
||||
def test_validate_config_rejects_invalid_url() -> None:
|
||||
values = _full_values()
|
||||
values["SITE_URL"] = "not-a-url"
|
||||
with pytest.raises(ConfigError, match="SITE_URL"):
|
||||
validate_config(values)
|
||||
|
||||
values = _full_values()
|
||||
values["MASTODON_BASE_URL"] = "ftp://mastodon.example"
|
||||
with pytest.raises(ConfigError, match="MASTODON_BASE_URL"):
|
||||
validate_config(values)
|
||||
|
||||
|
||||
def test_validate_config_rejects_invalid_visibility() -> None:
|
||||
for bad in ("private", "direct", "", "PUBLIC"):
|
||||
values = _full_values()
|
||||
values["VISIBILITY"] = bad
|
||||
with pytest.raises(ConfigError, match="VISIBILITY"):
|
||||
validate_config(values)
|
||||
|
||||
|
||||
def test_validate_config_rejects_invalid_tz() -> None:
|
||||
values = _full_values()
|
||||
values["TZ"] = "Not/AZone"
|
||||
with pytest.raises(ConfigError, match="TZ"):
|
||||
validate_config(values)
|
||||
|
||||
|
||||
def test_validate_config_rejects_negative_max_retries() -> None:
|
||||
values = _full_values()
|
||||
values["MAX_RETRIES"] = "-1"
|
||||
with pytest.raises(ConfigError, match="MAX_RETRIES"):
|
||||
validate_config(values)
|
||||
|
||||
|
||||
def test_validate_config_accepts_zero_max_retries() -> None:
|
||||
values = _full_values()
|
||||
values["MAX_RETRIES"] = "0"
|
||||
validate_config(values)
|
||||
|
||||
|
||||
def test_load_config_applies_optional_defaults(env_setup, monkeypatch) -> None:
|
||||
monkeypatch.delenv("THROWBACK_PREFIX", raising=False)
|
||||
monkeypatch.delenv("RETRY_COUNT", raising=False)
|
||||
monkeypatch.delenv("VISIBILITY", raising=False)
|
||||
monkeypatch.delenv("THROWNBACK_PREFIX", raising=False)
|
||||
monkeypatch.delenv("MAX_RETRIES", raising=False)
|
||||
monkeypatch.delenv("TZ", raising=False)
|
||||
|
||||
config = load_config()
|
||||
assert config.throwback_prefix == "Throwback:"
|
||||
assert config.retry_count == 3
|
||||
assert config.max_retries == 3
|
||||
assert config.tz == "Europe/Berlin"
|
||||
assert config.mastodon_visibility == "public"
|
||||
assert config.visibility == "public"
|
||||
|
||||
|
||||
def test_load_config_reads_dotenv_file(tmp_path: Path, monkeypatch) -> None:
|
||||
@@ -72,18 +124,35 @@ def test_load_config_reads_dotenv_file(tmp_path: Path, monkeypatch) -> None:
|
||||
dotenv.write_text(
|
||||
"MASTODON_BASE_URL=https://from-file.example\n"
|
||||
"MASTODON_ACCESS_TOKEN=file-token\n"
|
||||
"VISIBILITY=unlisted\n"
|
||||
"SITE_URL=https://blog.example.com\n"
|
||||
"HASHTAGS=#throwback\n"
|
||||
"THROWNBACK_PREFIX=Werferückblick:\n"
|
||||
"MAX_RETRIES=5\n"
|
||||
"RUN_AT=12:34\n"
|
||||
"TZ=Europe/Berlin\n"
|
||||
)
|
||||
|
||||
for key in ("MASTODON_BASE_URL", "MASTODON_ACCESS_TOKEN", "SITE_URL", "HASHTAGS", "RUN_AT"):
|
||||
for key in (
|
||||
"MASTODON_BASE_URL",
|
||||
"MASTODON_ACCESS_TOKEN",
|
||||
"VISIBILITY",
|
||||
"SITE_URL",
|
||||
"HASHTAGS",
|
||||
"THROWNBACK_PREFIX",
|
||||
"MAX_RETRIES",
|
||||
"RUN_AT",
|
||||
"TZ",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
config = load_config(dotenv_path=dotenv)
|
||||
assert config.mastodon_base_url == "https://from-file.example"
|
||||
assert config.mastodon_access_token == "file-token"
|
||||
assert config.run_at == "12:34"
|
||||
assert config.visibility == "unlisted"
|
||||
assert config.throwback_prefix == "Werferückblick:"
|
||||
assert config.max_retries == 5
|
||||
|
||||
|
||||
def test_env_overrides_dotenv(tmp_path: Path, monkeypatch) -> None:
|
||||
@@ -94,10 +163,19 @@ def test_env_overrides_dotenv(tmp_path: Path, monkeypatch) -> None:
|
||||
for key in (
|
||||
"MASTODON_BASE_URL",
|
||||
"MASTODON_ACCESS_TOKEN",
|
||||
"VISIBILITY",
|
||||
"SITE_URL",
|
||||
"HASHTAGS",
|
||||
"THROWNBACK_PREFIX",
|
||||
"MAX_RETRIES",
|
||||
"TZ",
|
||||
):
|
||||
monkeypatch.setenv(key, "x")
|
||||
monkeypatch.setenv("MASTODON_BASE_URL", "https://mastodon.example")
|
||||
monkeypatch.setenv("SITE_URL", "https://blog.example.com")
|
||||
monkeypatch.setenv("VISIBILITY", "public")
|
||||
monkeypatch.setenv("TZ", "Europe/Berlin")
|
||||
monkeypatch.setenv("MAX_RETRIES", "3")
|
||||
|
||||
config = load_config(dotenv_path=dotenv)
|
||||
assert config.run_at == "23:00"
|
||||
|
||||
@@ -103,8 +103,11 @@ def test_entrypoint_renders_run_at_into_cron() -> None:
|
||||
env = _entrypoint_env({
|
||||
"MASTODON_BASE_URL": "https://mastodon.example",
|
||||
"MASTODON_ACCESS_TOKEN": "x",
|
||||
"VISIBILITY": "public",
|
||||
"SITE_URL": "https://blog.example.com",
|
||||
"HASHTAGS": "#throwback",
|
||||
"THROWNBACK_PREFIX": "Throwback:",
|
||||
"MAX_RETRIES": "3",
|
||||
"RUN_AT": "09:00",
|
||||
"TZ": "Europe/Berlin",
|
||||
})
|
||||
|
||||
@@ -39,8 +39,11 @@ def test_entrypoint_rejects_bad_run_at(tmp_path: Path, monkeypatch) -> None:
|
||||
env = _clean_env()
|
||||
env["MASTODON_BASE_URL"] = "https://mastodon.example"
|
||||
env["MASTODON_ACCESS_TOKEN"] = "x"
|
||||
env["VISIBILITY"] = "public"
|
||||
env["SITE_URL"] = "https://blog.example.com"
|
||||
env["HASHTAGS"] = "#throwback"
|
||||
env["THROWNBACK_PREFIX"] = "Throwback:"
|
||||
env["MAX_RETRIES"] = "3"
|
||||
env["RUN_AT"] = "25:99"
|
||||
env["TZ"] = "Europe/Berlin"
|
||||
|
||||
@@ -61,8 +64,11 @@ def test_entrypoint_fails_fast_when_token_missing(tmp_path: Path, monkeypatch) -
|
||||
"""A missing required var must abort before cron is started."""
|
||||
env = _clean_env()
|
||||
env["MASTODON_BASE_URL"] = "https://mastodon.example"
|
||||
env["VISIBILITY"] = "public"
|
||||
env["SITE_URL"] = "https://blog.example.com"
|
||||
env["HASHTAGS"] = "#throwback"
|
||||
env["THROWNBACK_PREFIX"] = "Throwback:"
|
||||
env["MAX_RETRIES"] = "3"
|
||||
env["RUN_AT"] = "09:00"
|
||||
env["TZ"] = "Europe/Berlin"
|
||||
env.pop("MASTODON_ACCESS_TOKEN", None)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
|
||||
from tenbackward.logging_setup import (
|
||||
JsonFormatter,
|
||||
configure_json_logging,
|
||||
log_error,
|
||||
log_run_summary,
|
||||
log_startup,
|
||||
)
|
||||
|
||||
|
||||
def _capture(logger: logging.Logger) -> io.StringIO:
|
||||
buf = io.StringIO()
|
||||
handler = logging.StreamHandler(buf)
|
||||
handler.setFormatter(JsonFormatter())
|
||||
logger.handlers = [handler]
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
return buf
|
||||
|
||||
|
||||
def test_json_formatter_emits_valid_json() -> None:
|
||||
buf = _capture(logging.getLogger("tenbackward.test"))
|
||||
logging.getLogger("tenbackward.test").info("hello", extra={"foo": 1})
|
||||
line = buf.getvalue().strip()
|
||||
payload = json.loads(line)
|
||||
assert payload["message"] == "hello"
|
||||
assert payload["level"] == "INFO"
|
||||
assert payload["logger"] == "tenbackward.test"
|
||||
assert payload["foo"] == 1
|
||||
assert "ts" in payload
|
||||
|
||||
|
||||
def test_json_formatter_redacts_secret_keys() -> None:
|
||||
buf = _capture(logging.getLogger("tenbackward.test"))
|
||||
logging.getLogger("tenbackward.test").info(
|
||||
"msg",
|
||||
extra={"access_token": "secret-value", "site_url": "https://x"},
|
||||
)
|
||||
payload = json.loads(buf.getvalue().strip())
|
||||
assert payload["access_token"] == "[REDACTED]"
|
||||
assert payload["site_url"] == "https://x"
|
||||
|
||||
|
||||
def test_log_run_summary_emits_one_line_with_counters() -> None:
|
||||
buf = _capture(logging.getLogger("tenbackward"))
|
||||
log_run_summary(3, 2, 1, 1, ["x"])
|
||||
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"] == 3
|
||||
assert payload["matched"] == 2
|
||||
assert payload["posted"] == 1
|
||||
assert payload["skipped"] == 1
|
||||
assert payload["posted_ids"] == ["x"]
|
||||
|
||||
|
||||
def test_log_run_summary_silent_when_no_matches() -> None:
|
||||
buf = _capture(logging.getLogger("tenbackward"))
|
||||
log_run_summary(0, 0, 0, 0, [])
|
||||
assert buf.getvalue() == ""
|
||||
|
||||
|
||||
def test_log_startup_emits_single_info_line() -> None:
|
||||
buf = _capture(logging.getLogger("tenbackward"))
|
||||
log_startup(version="0.1.0", site_url="https://x", run_at="09:00", tz="Europe/Berlin")
|
||||
payload = json.loads(buf.getvalue().strip())
|
||||
assert payload["event"] == "startup"
|
||||
assert payload["version"] == "0.1.0"
|
||||
|
||||
|
||||
def test_log_error_redacts_secret_keys_and_exposes_exc_type() -> None:
|
||||
buf = _capture(logging.getLogger("tenbackward"))
|
||||
log_error("boom", exc=RuntimeError("x"), access_token="abc")
|
||||
payload = json.loads(buf.getvalue().strip())
|
||||
assert payload["level"] == "ERROR"
|
||||
assert payload["event"] == "boom"
|
||||
assert payload["access_token"] == "[REDACTED]"
|
||||
assert payload["exc_type"] == "RuntimeError"
|
||||
|
||||
|
||||
def test_configure_json_logging_is_idempotent() -> None:
|
||||
configure_json_logging()
|
||||
configure_json_logging()
|
||||
root = logging.getLogger()
|
||||
json_handlers = [h for h in root.handlers if isinstance(h.formatter, JsonFormatter)]
|
||||
assert len(json_handlers) == 1
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tenbackward import main as main_module
|
||||
from tenbackward.logging_setup import JsonFormatter
|
||||
from tenbackward.main import main
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def capture_logger() -> io.StringIO:
|
||||
buf = io.StringIO()
|
||||
handler = logging.StreamHandler(buf)
|
||||
handler.setFormatter(JsonFormatter())
|
||||
target = logging.getLogger("tenbackward")
|
||||
target.handlers = [handler]
|
||||
target.setLevel(logging.INFO)
|
||||
target.propagate = False
|
||||
return buf
|
||||
|
||||
|
||||
def _run_lines(buf: io.StringIO) -> list[dict]:
|
||||
return [json.loads(line) for line in buf.getvalue().splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _seed_state(data_dir: Path, ids: list[str]) -> None:
|
||||
from tenbackward.state import save_posted
|
||||
|
||||
state = {pid: {"posted_at": "2024-01-01T00:00:00+00:00"} for pid in ids}
|
||||
save_posted(data_dir, state)
|
||||
|
||||
|
||||
def test_run_emits_one_info_summary_on_success(env_setup, data_dir, capture_logger, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DATA_DIR", str(data_dir))
|
||||
|
||||
monkeypatch.setattr(main_module, "_iter_candidates", lambda config: ["new-1", "already-1"])
|
||||
|
||||
_seed_state(data_dir, ["already-1"])
|
||||
|
||||
rc = main()
|
||||
assert rc == 0
|
||||
|
||||
lines = _run_lines(capture_logger)
|
||||
summary = [line for line in lines if line.get("event") == "run_complete"]
|
||||
assert len(summary) == 1
|
||||
payload = summary[0]
|
||||
assert payload["scanned"] == 2
|
||||
assert payload["matched"] == 2
|
||||
assert payload["posted"] == 1
|
||||
assert payload["skipped"] == 1
|
||||
assert payload["posted_ids"] == ["new-1"]
|
||||
|
||||
from tenbackward.state import load_posted
|
||||
|
||||
state = load_posted(data_dir)
|
||||
assert "new-1" in state
|
||||
assert "already-1" in state
|
||||
|
||||
|
||||
def test_run_silent_when_no_matches(env_setup, data_dir, capture_logger, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DATA_DIR", str(data_dir))
|
||||
monkeypatch.setattr(main_module, "_iter_candidates", lambda config: [])
|
||||
|
||||
rc = main()
|
||||
assert rc == 0
|
||||
|
||||
lines = _run_lines(capture_logger)
|
||||
summary = [line for line in lines if line.get("event") == "run_complete"]
|
||||
assert summary == []
|
||||
|
||||
|
||||
def test_run_returns_nonzero_on_pipeline_error(env_setup, data_dir, capture_logger, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DATA_DIR", str(data_dir))
|
||||
monkeypatch.setenv("MAX_RETRIES", "1")
|
||||
|
||||
def _boom(config):
|
||||
raise RuntimeError("boom-token-should-not-appear")
|
||||
|
||||
monkeypatch.setattr(main_module, "_iter_candidates", _boom)
|
||||
|
||||
rc = main()
|
||||
assert rc == 1
|
||||
|
||||
lines = _run_lines(capture_logger)
|
||||
summary = [line for line in lines if line.get("event") == "run_complete"]
|
||||
assert summary == []
|
||||
|
||||
error_lines = [line for line in lines if line.get("level") == "ERROR"]
|
||||
assert any("exc_type" in line for line in error_lines)
|
||||
assert "boom-token-should-not-appear" not in capture_logger.getvalue()
|
||||
|
||||
|
||||
def test_run_distinguishes_posted_from_skipped_via_ids(env_setup, data_dir, capture_logger, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DATA_DIR", str(data_dir))
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"_iter_candidates",
|
||||
lambda config: ["alpha", "beta", "gamma"],
|
||||
)
|
||||
_seed_state(data_dir, ["beta"])
|
||||
|
||||
rc = main()
|
||||
assert rc == 0
|
||||
|
||||
summary = [line for line in _run_lines(capture_logger) if line.get("event") == "run_complete"]
|
||||
assert len(summary) == 1
|
||||
payload = summary[0]
|
||||
assert set(payload["posted_ids"]) == {"alpha", "gamma"}
|
||||
assert "beta" not in payload["posted_ids"]
|
||||
assert payload["skipped"] == 1
|
||||
assert payload["posted"] == 2
|
||||
Reference in New Issue
Block a user