mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 10:43:14 +00:00
Some checks are pending
Deploy / deploy (push) Waiting to run
Both email-processor God-handlers split along the seams that already work in the flat-sibling pattern established by lambdas/shared/, so bare-name imports keep working under the existing bundling glob. PO (5-way split): handler.py keeps only the event loop, fail-closed auth, and email_type routing. extraction.py holds extract_with_claude and _EMAIL_TAG_RE, importing EXTRACTION_PROMPT from prompts.py and parse_raw_email from shared/email_parsing.py rather than recreating a PO-local copy. enrichment.py is a pure code move of enrich_parsed and pad_zip (PO-only; WO has no enrichment stage) with zero behavior change. telemetry.py holds the EMF ParseMethod emit wrappers. persistence.py holds _write_fields/_merge_update/save_*, collapsing the byte-identical save_new_po/save_revision bodies into one _save_merge helper that both now call through, preserving the sticky Cancelled ConditionExpression guard for both callers; save_cancellation stays separate. WO (5 concerns, no enrichment stage): the handler loop keeps validate_ai_fallback and the re.fullmatch(r"[0-9]+", work_order_id) key guard ahead of both save_work_order and save_event, since the guard protects the DynamoDB partition key and the '#'-delimited comment_id range-key segment. _header_date_iso and comment_id determinism stay colocated with persistence.py's save_event for the retry-idempotent event_id key. EXTRACTION_PROMPT (PO) moves to prompts.py with cross-reference headers to derived_fields.py's authoritative trade/site/fiscal rule tables; handler.py re-exports it (from prompts import EXTRACTION_PROMPT) since four tests dereference handler.EXTRACTION_ PROMPT directly. WO's prompt moves the same way. I/O modules (extraction.py's bedrock client, persistence.py's dynamodb resource, handler.py's s3 client) get lazy cached boto3 accessors; pure modules (enrichment.py, prompts.py, telemetry.py) import no boto3. Test monkeypatch surfaces move to the module that now owns the client (e.g. persistence.dynamodb) everywhere tests patch it, and the moto-before-handler-import ordering in _po_parser_support.py is preserved so the moto-backed suites don't hit real AWS. Behavior-preservation pins, verified with tests: PO still emits ParseMethod=ai_fallback before the Bedrock call, with ai_fallback_rejected as the additive second datapoint on rejection. WO still emits after its gate with mutually-exclusive ai_fallback / ai_fallback_rejected. Shadow DerivedFieldAgreement telemetry stays ai_fallback-only. derived_fields.py is untouched (diff against feature/phase-3-shared-extraction is empty). handler(event, context) signatures and the save_* public contract are unchanged on both pipelines; goldens unchanged. PO_EXPECTED_TOP_LEVEL_MODULES and its WO equivalent in tests/test_bundle_consistency.py are updated for the new sibling modules so the AST bundle-consistency test still fails on an unshipped or uncommented-out sibling.
176 lines
6.9 KiB
Python
176 lines
6.9 KiB
Python
"""Shared pytest configuration for the procurement-ingest test suite.
|
|
|
|
The Lambda handlers create boto3 clients at module import time, so a
|
|
region and dummy credentials must be present in the environment before
|
|
any handler module is imported. Setting them here at conftest import
|
|
time guarantees they exist before test collection touches a handler.
|
|
"""
|
|
|
|
import importlib.util
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
|
|
os.environ.setdefault("AWS_ACCESS_KEY_ID", "testing")
|
|
os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "testing")
|
|
os.environ.setdefault("AWS_SESSION_TOKEN", "testing")
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
_SHARED_DIR = REPO_ROOT / "lambdas" / "shared"
|
|
|
|
|
|
# Sibling modules imported by bare name from the handlers (the Lambda runtime
|
|
# puts each function's own directory on sys.path; the CDK bundling then cp's the
|
|
# shared modules in flat beside handler.py so those bare imports resolve too).
|
|
# template_parser/derived_fields are duplicated PER PIPELINE, so their bare
|
|
# names MUST be bound to the right pipeline's file around each handler exec --
|
|
# relying on sys.path ordering (or on whatever a previously collected suite left
|
|
# in sys.modules) silently binds a handler to the OTHER pipeline's sibling.
|
|
# ses_auth/email_parsing/emf are now single-sourced under lambdas/shared/ (Phase
|
|
# 3); the loop below resolves them from there via a shared-dir fallback.
|
|
#
|
|
# Phase 5 decomposed each God-handler into flat siblings (prompts/telemetry/
|
|
# extraction/enrichment/persistence). The order below is DEPENDENCY-TOPOLOGICAL,
|
|
# not alphabetical: the loader binds each bare name in sys.modules right after
|
|
# exec'ing it, so a sibling whose module body does `from <x> import ...` must
|
|
# appear AFTER <x> here or its exec ImportErrors. The load-bearing edges are
|
|
# emf < telemetry, prompts < extraction, and derived_fields + telemetry <
|
|
# enrichment. WO has no enrichment/derived_fields sibling -- the loader's
|
|
# `if not sibling_path.exists(): continue` silently skips them there, so one
|
|
# unified tuple serves both pipelines.
|
|
_SIBLING_MODULES = (
|
|
"ses_auth",
|
|
"email_parsing",
|
|
"emf",
|
|
"prompts",
|
|
"template_parser",
|
|
"derived_fields",
|
|
"telemetry",
|
|
"extraction",
|
|
"enrichment",
|
|
"persistence",
|
|
)
|
|
|
|
|
|
def _load_module(path, module_name):
|
|
if module_name in sys.modules:
|
|
return sys.modules[module_name]
|
|
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[module_name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def load_handler(relative_path, module_name):
|
|
"""Load a Lambda handler module by file path under a unique name.
|
|
|
|
The handler files all share the basename ``handler.py`` and are not
|
|
importable as packages, so a plain ``import handler`` would collide
|
|
across Lambdas. The same loader serves the ``ses_auth.py`` modules,
|
|
which are likewise duplicated per pipeline and not importable as
|
|
packages.
|
|
|
|
Handler modules import their siblings by bare name (e.g. ``from
|
|
template_parser import try_deterministic_parse``). Each sibling is loaded
|
|
from the handler's own directory under a unique module name and registered
|
|
under its bare name only for the duration of the handler exec, then the
|
|
previous binding is restored -- so this loader is deterministic regardless
|
|
of collection order and of what the per-Lambda test suites (which put
|
|
their own module dir on sys.path) have already cached in sys.modules.
|
|
"""
|
|
path = REPO_ROOT / relative_path
|
|
if module_name in sys.modules:
|
|
return sys.modules[module_name]
|
|
# Keep the handler dir on sys.path for parity with the Lambda runtime.
|
|
handler_dir = str(path.parent)
|
|
if handler_dir not in sys.path:
|
|
sys.path.insert(0, handler_dir)
|
|
if path.name != "handler.py":
|
|
# Leaf modules (e.g. ses_auth.py itself) have no sibling imports.
|
|
return _load_module(path, module_name)
|
|
saved = {}
|
|
for sibling in _SIBLING_MODULES:
|
|
# Per-pipeline siblings (template_parser/derived_fields) resolve next to
|
|
# the handler; the shared, single-sourced siblings (ses_auth/
|
|
# email_parsing/emf) fall back to lambdas/shared/. No ambiguity: post
|
|
# Phase 3 the shared names exist ONLY under shared/, the per-pipeline
|
|
# names ONLY next to the handler.
|
|
sibling_path = path.parent / f"{sibling}.py"
|
|
if not sibling_path.exists():
|
|
sibling_path = _SHARED_DIR / f"{sibling}.py"
|
|
if not sibling_path.exists():
|
|
continue
|
|
saved[sibling] = sys.modules.get(sibling)
|
|
sys.modules[sibling] = _load_module(sibling_path, f"{module_name}__{sibling}")
|
|
try:
|
|
module = _load_module(path, module_name)
|
|
finally:
|
|
for sibling, previous in saved.items():
|
|
if previous is not None:
|
|
sys.modules[sibling] = previous
|
|
else:
|
|
sys.modules.pop(sibling, None)
|
|
return module
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def po_handler():
|
|
"""The PO email processor handler module."""
|
|
return load_handler(
|
|
"lambdas/po/email_processor/handler.py",
|
|
"po_email_processor_handler",
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def po_persistence(po_handler):
|
|
"""The PO persistence sibling (owns PO_TABLE + save_* after Phase 5).
|
|
|
|
load_handler registers each sibling under ``<handler>__<sibling>`` in
|
|
sys.modules (only the bare-name binding is restored afterwards), so the
|
|
persistence module stays reachable here by its unique name. The root
|
|
tests/test_po_merge.py keys its moto table on ``po_persistence.PO_TABLE``
|
|
rather than a hardcoded literal.
|
|
"""
|
|
return sys.modules["po_email_processor_handler__persistence"]
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def po_enrichment(po_handler):
|
|
"""The PO enrichment sibling (owns pad_zip after Phase 5).
|
|
|
|
pad_zip is PURE and NOT re-exported by handler (handler's body never calls
|
|
it -- it runs inside enrich_parsed), so tests/test_pad_zip.py dereferences it
|
|
on the owning module instead of po_handler.
|
|
"""
|
|
return sys.modules["po_email_processor_handler__enrichment"]
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def wo_handler():
|
|
"""The WO email processor handler module."""
|
|
return load_handler(
|
|
"lambdas/wo/email_processor/handler.py",
|
|
"wo_email_processor_handler",
|
|
)
|
|
|
|
|
|
@pytest.fixture(params=["po_handler", "wo_handler"])
|
|
def email_handler(request):
|
|
"""Parametrized fixture yielding each email processor handler module."""
|
|
return request.getfixturevalue(request.param)
|
|
|
|
|
|
@pytest.fixture
|
|
def ses_auth():
|
|
"""The single-sourced ses_auth module (lambdas/shared/, Phase 3).
|
|
|
|
Previously parameterized over the two per-pipeline copies to prove they
|
|
stayed byte-identical; now there is exactly one copy, so this loads it
|
|
once -- halving the test_ses_auth run.
|
|
"""
|
|
return load_handler("lambdas/shared/ses_auth.py", "shared_ses_auth")
|