diff --git a/Dockerfile b/Dockerfile index f4d8eaa..435ba4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ UV_COMPILE_BYTECODE=1 \ UV_NO_CACHE=1 \ + UV_PYTHON_INSTALL_DIR=/opt/python \ PATH="/app/.venv/bin:${PATH}" \ PORT=8080 @@ -24,4 +25,6 @@ RUN python -m pip install --upgrade pip uv \ USER appuser -CMD ["gunicorn", "--bind", ":8080", "main:app"] +RUN /app/.venv/bin/python -m scripts.publish_digest_before_read --help + +CMD ["/app/.venv/bin/python", "-m", "gunicorn", "--bind", ":8080", "main:app"] diff --git a/docs/unified-daily-notification.md b/docs/unified-daily-notification.md index 83245aa..484805e 100644 --- a/docs/unified-daily-notification.md +++ b/docs/unified-daily-notification.md @@ -65,6 +65,10 @@ monitoring checks, but run the archive-only publisher independently in an approved Cloud Run Job using the platform image and its frozen dependencies. Command: `/app/.venv/bin/python -m scripts.publish_digest_before_read`. +Use `--help` for an offline CLI check; it exits before publication or the watchdog. +The image keeps uv-managed Python under `/opt/python` so `appuser` can traverse +its path. Build checks and runtime commands use `/app/.venv/bin/python` directly; +the Docker build also checks publisher help after switching to `appuser`. The command imports the existing producer, never the service entrypoint. It does not run a drill, refresh a broker session, invoke a trading route, send Telegram, or access Secret Manager. Archive reads use the Storage SDK and the job's diff --git a/scripts/publish_digest_before_read.py b/scripts/publish_digest_before_read.py index c416209..90f1881 100644 --- a/scripts/publish_digest_before_read.py +++ b/scripts/publish_digest_before_read.py @@ -1,6 +1,7 @@ """Publish one completed-window candidate before the central read, without broker calls.""" from __future__ import annotations +import argparse import json import os import signal @@ -114,7 +115,9 @@ def publish(env, *, now=None, client=None, emit=None): return {"status": "published", "business_day": day, "readback_verified": True} -def main(): +def main(argv=None): + argparse.ArgumentParser(description=__doc__).parse_args(argv) + def timeout(signum, frame): raise DeadlineExceeded() diff --git a/tests/test_publish_digest_before_read.py b/tests/test_publish_digest_before_read.py index 9f224d8..cfb557c 100644 --- a/tests/test_publish_digest_before_read.py +++ b/tests/test_publish_digest_before_read.py @@ -169,7 +169,7 @@ def test_main_redacts_exception_and_clears_watchdog(monkeypatch, capsys): monkeypatch.setattr(publisher.signal, "signal", lambda *a: None) monkeypatch.setattr(publisher.signal, "alarm", alarms.append) monkeypatch.setattr(publisher, "publish", lambda *a: (_ for _ in ()).throw(RuntimeError("private-value"))) - assert publisher.main() == 1 + assert publisher.main([]) == 1 output = capsys.readouterr().out assert "private-value" not in output assert json.loads(output)["status"] == "failed" @@ -185,7 +185,7 @@ def emit(*a): except Exception: pytest.fail("deadline must escape producer catch-all") monkeypatch.setattr(publisher, "publish", emit) - assert publisher.main() == 1 + assert publisher.main([]) == 1 assert json.loads(capsys.readouterr().out)["status"] == "failed" @@ -212,7 +212,7 @@ def fail(*a): monkeypatch.setattr(publisher, "publish", fail) monkeypatch.setattr(publisher.signal, "signal", lambda *a: None) monkeypatch.setattr(publisher.signal, "alarm", lambda *a: None) - assert publisher.main() == 1 + assert publisher.main([]) == 1 output = capsys.readouterr().out assert "private resource" not in output assert json.loads(output)["stage"] == stage @@ -250,3 +250,17 @@ def test_invalid_runtime_identity_is_rejected_before_idle_projection(key, value) with pytest.raises(ValueError, match="target_invalid"): publisher.publish({**ENV, "RUNTIME_TARGET_JSON": json.dumps(target)}, now=lambda: NOW, emit=lambda *a, **kw: pytest.fail("must not project an invalid target")) + + +@pytest.mark.parametrize("args, exit_code", [(["--help"], 0), (["--unknown"], 2)]) +def test_cli_arguments_exit_before_publication_or_watchdog(args, exit_code, monkeypatch, capsys): + def unexpected(*a, **kw): + pytest.fail("CLI argument handling must not start publication or the watchdog") + monkeypatch.setattr(publisher, "publish", unexpected) + monkeypatch.setattr(publisher.signal, "signal", unexpected) + monkeypatch.setattr(publisher.signal, "alarm", unexpected) + with pytest.raises(SystemExit) as caught: + publisher.main(args) + assert caught.value.code == exit_code + output = capsys.readouterr() + assert "usage:" in (output.out if exit_code == 0 else output.err)