diff --git a/services/earnings/jobs/capture.py b/services/earnings/jobs/capture.py index 91c828ff..79e109c5 100644 --- a/services/earnings/jobs/capture.py +++ b/services/earnings/jobs/capture.py @@ -86,6 +86,7 @@ from __future__ import annotations import contextlib +import importlib import json import logging import os @@ -96,6 +97,22 @@ _LOG = logging.getLogger("services.earnings.jobs.capture") + +def _load_gcs_storage() -> object: + """Resolve ``google.cloud.storage``, honoring a ``sys.modules`` stub. + + Uses :func:`importlib.import_module` instead of ``from google.cloud import + storage``. ``google.cloud`` is a PEP-420 namespace package: once the real + ``google.cloud.storage`` submodule is imported anywhere in the process (e.g. + transitively via ``gcsfs`` under the ``[satellite]`` extra) Python binds it as + an attribute on the ``google.cloud`` package object, and a ``from google.cloud + import storage`` statement then reads that attribute and skips ``sys.modules`` — + silently bypassing a test's injected fake. ``importlib.import_module`` consults + ``sys.modules`` first, so the fake wins regardless of import order; runtime + behavior is byte-identical when no stub is present.""" + return importlib.import_module("google.cloud.storage") + + #: The Pub/Sub ack-deadline (seconds) the lease loop resets the message to on each #: extension. Pub/Sub caps modify_ack_deadline at 600s; a 60-90 min capture needs #: many extensions, so we re-lease at 600 and refresh well before it lapses. @@ -270,9 +287,11 @@ def _upload_handoff(audio_path: str, bucket: str, *, ticker: str, call_id: str) The cross-service handoff to STT (capture and STT do NOT share a disk). The object key is namespaced by ``(ticker, call_id)``; it lands in the private, in-firewall ``AUDIO_HANDOFF_BUCKET`` — NOT R2, NEVER served. STT downloads it, - transcribes, and deletes it. ``google-cloud-storage`` is lazy-imported here. + transcribes, and deletes it. ``google-cloud-storage`` is lazy-imported here + (via importlib so a test's sys.modules stub is honored even after the real + submodule was imported elsewhere — see _load_gcs_storage). """ - from google.cloud import storage + storage = _load_gcs_storage() ext = os.path.splitext(audio_path)[1] or ".audio" blob_name = f"handoff/{ticker}/{call_id}{ext}" diff --git a/services/earnings/jobs/stt.py b/services/earnings/jobs/stt.py index a0391192..3eabff3d 100644 --- a/services/earnings/jobs/stt.py +++ b/services/earnings/jobs/stt.py @@ -57,6 +57,7 @@ from __future__ import annotations import contextlib +import importlib import logging import os import tempfile @@ -64,6 +65,26 @@ from services.earnings.jobs._env import optional_env, require_env + +def _load_gcs_storage() -> object: + """Resolve the ``google.cloud.storage`` module, honoring a ``sys.modules`` stub. + + Uses :func:`importlib.import_module` rather than ``from google.cloud import + storage``. ``google.cloud`` is a PEP-420 namespace package: the first time the + real ``google.cloud.storage`` submodule is imported ANYWHERE in the process + (e.g. transitively via ``gcsfs`` under the ``[satellite]`` extra), Python binds + it as an attribute on the already-loaded ``google.cloud`` package object. After + that, a ``from google.cloud import storage`` statement reads that bound + attribute and NEVER consults ``sys.modules`` — so a test's + ``monkeypatch.setitem(sys.modules, "google.cloud.storage", fake)`` is silently + bypassed and the real client is used (a test-isolation footgun that only + surfaces in a venv where something already imported the real submodule). + ``importlib.import_module`` consults ``sys.modules`` FIRST, so the injected fake + wins regardless of import order. Runtime behavior is byte-identical to the + import statement when no stub is present (same real module object).""" + return importlib.import_module("google.cloud.storage") + + _LOG = logging.getLogger("services.earnings.jobs.stt") @@ -118,7 +139,10 @@ def _resolve_audio_reference( bucket, key = gs # Lazy import: google-cloud-storage only when a GCS handoff object is fetched. - from google.cloud import storage + # Resolved via importlib (not `from google.cloud import storage`) so a test's + # sys.modules stub is honored even after the real submodule was imported + # elsewhere (see _load_gcs_storage). + storage = _load_gcs_storage() ext = os.path.splitext(key)[1] or ".audio" fd, local_path = tempfile.mkstemp(prefix="earnings-stt-handoff-", suffix=ext) @@ -143,7 +167,7 @@ def _delete_handoff_source(bucket: str, key: str) -> None: data (the ledger has the transcript) and is logged loudly (the bucket lifecycle policy is the backstop) rather than failing the already-successful call. """ - from google.cloud import storage + storage = _load_gcs_storage() try: storage.Client().bucket(bucket).blob(key).delete()