diff --git a/.vscode/launch.json b/.vscode/launch.json index 7107abd..2d93b6c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -487,7 +487,11 @@ //"args": ["-v", "1", "--auv_name", "ahi", "--start", "20251001", "--end", "20251231", "--update_ssds_provenance", "--force"] // Test web page building with a short deployment //"args": ["-v", "1", "--dlist", "ahi/missionlogs/2025/20251022_20251024.dlist", "--update_ssds_provenance"] - "args": ["-v", "1", "--last_n_days", "10", "--update_ssds_provenance", "--force"] + // Test --force option for rebuilding web pages with a short deployment + //"args": ["-v", "1", "--last_n_days", "10", "--update_ssds_provenance", "--force"] + // Test --notify option + "args": ["-v", "1", "--dlist", "ahi/missionlogs/2025/20251022_20251024.dlist", "--update_ssds_provenance", "--force", "--notify", "mccann@mbari.org"] + }, ] diff --git a/src/data/lrauv_deployment_plots.py b/src/data/lrauv_deployment_plots.py index 5660107..f1f6d96 100755 --- a/src/data/lrauv_deployment_plots.py +++ b/src/data/lrauv_deployment_plots.py @@ -18,6 +18,7 @@ import argparse # noqa: I001 import http import logging +import os import re import sys import time @@ -36,6 +37,8 @@ from provenance import get_dods_url, get_script_github_url, submit_process_run from resample import FREQ, LRAUV_OPENDAP_BASE +ENV_LRAUV_NOTIFY = "LRAUV_NOTIFY" + class DeploymentPlotter: logger = logging.getLogger(__name__) @@ -189,12 +192,13 @@ def _deployment_has_outputs(self, deployment_dir: Path, plot_name_stem: str) -> """Return True if any per-deployment PNG already exists in *deployment_dir*.""" return any(deployment_dir.glob(f"{plot_name_stem}_*.png")) - def plot_deployment( # noqa: C901, PLR0912, PLR0915 + def plot_deployment( # noqa: C901, PLR0912, PLR0913, PLR0915 self, dlist: str, verbose: int = 0, update_ssds_provenance: bool = False, # noqa: FBT001, FBT002 force: bool = False, # noqa: FBT001, FBT002 + notify: str | None = None, ) -> None: """Main entry point: generate deployment-level plots from a .dlist path. @@ -203,6 +207,8 @@ def plot_deployment( # noqa: C901, PLR0912, PLR0915 verbose: Verbosity level (0-2). update_ssds_provenance: Submit provenance records to SSDS_Metadata. force: Reprocess even when output PNGs already exist. + notify: Email address or Slack webhook URL to notify after completion. + Falls back to the ``LRAUV_NOTIFY`` environment variable. """ self.logger.setLevel(self._log_levels[min(verbose, 2)]) @@ -317,6 +323,7 @@ def plot_deployment( # noqa: C901, PLR0912, PLR0915 nc_files, verbose=verbose, update_ssds_provenance=update_ssds_provenance, + notify=notify, ) def _build_and_write_html( # noqa: PLR0913 @@ -330,6 +337,7 @@ def _build_and_write_html( # noqa: PLR0913 nc_files: list[str], verbose: int = 0, update_ssds_provenance: bool = False, # noqa: FBT001, FBT002 + notify: str | None = None, ) -> None: """Fetch STOQS permalink and write per-PNG HTML pages.""" dlist_no_ext = str(Path(dlist).with_suffix("")) @@ -358,6 +366,10 @@ def _build_and_write_html( # noqa: PLR0913 archiver = Archiver(add_handlers=True, clobber=True) archiver.logger.setLevel(self._log_levels[min(verbose, 2)]) archiver.copy_lrauv_deployment(deployment_dir, plot_name_stem) + html_paths = [ + Path(p).with_suffix(".html") for p in png_paths if Path(p).with_suffix(".html").exists() + ] + self._notify(notify or "", raw_name or plot_name_stem, html_paths, stoqs_url) if update_ssds_provenance: self._submit_provenance( deployment_dir=deployment_dir, @@ -368,6 +380,60 @@ def _build_and_write_html( # noqa: PLR0913 nc_files=nc_files, ) + def _notify( + self, + target: str, + deployment_name: str, + html_paths: list[Path], + stoqs_url: str | None, + ) -> None: + """Send an email or Slack notification with links to the new deployment HTML pages. + + *target* is auto-detected: + - starts with ``https://`` → treated as a Slack incoming-webhook URL + - anything else → treated as an email address (sent via localhost SMTP) + + The ``LRAUV_NOTIFY`` environment variable can supply the target so it + stays out of shell history and cron job command lines. + """ + resolved = target or os.environ.get(ENV_LRAUV_NOTIFY, "") + if not resolved: + return + + lines = [f"New LRAUV deployment plots available: {deployment_name}"] + for p in html_paths: + lines.append(f" {get_dods_url(str(p))}") # noqa: PERF401 + if stoqs_url: + lines.append(f"STOQS: {stoqs_url}") + body = "\n".join(lines) + + if resolved.startswith("https://"): + # Slack incoming webhook + import requests # noqa: PLC0415 + + try: + resp = requests.post(resolved, json={"text": body}, timeout=10) # noqa: S113 + resp.raise_for_status() + self.logger.info("Slack notification sent to webhook") + except Exception as exc: # noqa: BLE001 + self.logger.warning("Slack notification failed: %s", exc) + else: + # Email via localhost SMTP relay + import smtplib # noqa: PLC0415 + from email.message import EmailMessage # noqa: PLC0415 + + msg = EmailMessage() + msg["Subject"] = f"New LRAUV deployment plots: {deployment_name}" + msg["From"] = "auv-python@mbari.org" + msg["To"] = resolved + msg.set_content(body) + try: + with smtplib.SMTP("localhost") as s: + s.send_message(msg) + self.logger.info("Email notification sent to %s", resolved) + except Exception as exc: # noqa: BLE001 + self.logger.warning("Email notification failed: %s", exc) + def _submit_provenance( # noqa: PLR0913 self, deployment_dir: Path, @@ -693,6 +759,16 @@ def process_command_line(self) -> None: " By default, deployments with existing outputs are skipped." ), ) + parser.add_argument( + "--notify", + default="", + metavar="EMAIL_OR_WEBHOOK", + help=( + "Send a notification when new plots are written. Provide an email" + " address or a Slack incoming-webhook URL. Falls back to the" + f" {ENV_LRAUV_NOTIFY} environment variable if not specified." + ), + ) self.args = parser.parse_args() if self.args.start and not self.args.end: self.args.end = datetime.now(tz=UTC).strftime("%Y%m%d") @@ -725,4 +801,5 @@ def process_command_line(self) -> None: verbose=args.verbose, update_ssds_provenance=args.update_ssds_provenance, force=args.force, + notify=args.notify, ) diff --git a/src/data/test_lrauv_deployment_plots.py b/src/data/test_lrauv_deployment_plots.py index d97fd83..31dc65f 100644 --- a/src/data/test_lrauv_deployment_plots.py +++ b/src/data/test_lrauv_deployment_plots.py @@ -634,3 +634,86 @@ def test_depth_range_in_permalink(self): # padding of 1 m applied each side assert payload["start-depth"] == pytest.approx(9.0) # noqa: S101 assert payload["end-depth"] == pytest.approx(51.0) # noqa: S101 + + +# =========================================================================== +# Tests for _notify() +# =========================================================================== + + +class TestNotify: + """Unit tests for DeploymentPlotter._notify().""" + + def test_email_sent(self, dp, tmp_path): + """_notify() with an email target should call smtplib.SMTP.send_message.""" + html_file = tmp_path / "test.html" + html_file.touch() + + with patch("smtplib.SMTP") as mock_smtp_cls: + mock_smtp = MagicMock() + mock_smtp_cls.return_value.__enter__ = MagicMock(return_value=mock_smtp) + mock_smtp_cls.return_value.__exit__ = MagicMock(return_value=False) + dp._notify( + "team@mbari.org", + "CANON_April_2025", + [html_file], + "https://stoqs.mbari.org/query/?permalink_id=abc", + ) + + mock_smtp.send_message.assert_called_once() # noqa: S101 + msg = mock_smtp.send_message.call_args[0][0] + assert "team@mbari.org" in msg["To"] # noqa: S101 + assert "CANON_April_2025" in msg["Subject"] # noqa: S101 + + def test_slack_webhook_posts(self, dp, tmp_path): + """_notify() with a Slack webhook URL should POST to that URL.""" + html_file = tmp_path / "test.html" + html_file.touch() + webhook_url = "https://hooks.slack.com/services/T0000/B0000/xxxx" + + with patch("requests.post") as mock_post: + mock_post.return_value.raise_for_status = MagicMock() + dp._notify( + "https://hooks.slack.com/services/T0000/B0000/xxxx", + "CANON_April_2025", + [html_file], + None, + ) + + mock_post.assert_called_once() # noqa: S101 + call_kwargs = mock_post.call_args + assert call_kwargs[0][0] == webhook_url # noqa: S101 + assert "text" in call_kwargs[1]["json"] # noqa: S101 + + def test_noop_when_no_target_and_no_env(self, dp, tmp_path, monkeypatch): + """_notify() must not call anything when target is empty and env var unset.""" + monkeypatch.delenv("LRAUV_NOTIFY", raising=False) + + with ( + patch("smtplib.SMTP") as mock_smtp_cls, + patch("requests.post") as mock_post, + ): + dp._notify("", "CANON_April_2025", [], None) + + mock_smtp_cls.assert_not_called() # noqa: S101 + mock_post.assert_not_called() # noqa: S101 + + def test_env_var_fallback_used(self, dp, tmp_path, monkeypatch): + """When notify='' but LRAUV_NOTIFY env var is set, that value is used.""" + monkeypatch.setenv("LRAUV_NOTIFY", "fallback@mbari.org") + html_file = tmp_path / "test.html" + html_file.touch() + + with ( + patch("lrauv_deployment_plots.os") as mock_os, + patch("smtplib.SMTP") as mock_smtp_cls, + ): + mock_os.environ = {"LRAUV_NOTIFY": "fallback@mbari.org"} + mock_smtp = MagicMock() + mock_smtp_cls.return_value.__enter__ = MagicMock(return_value=mock_smtp) + mock_smtp_cls.return_value.__exit__ = MagicMock(return_value=False) + dp._notify("", "CANON_April_2025", [html_file], None) + + mock_smtp.send_message.assert_called_once() # noqa: S101 + msg = mock_smtp.send_message.call_args[0][0] + assert "fallback@mbari.org" in msg["To"] # noqa: S101