From ddb4281d14cbddad5333686624dd9db7adcb299f Mon Sep 17 00:00:00 2001 From: stranske-automation-bot Date: Wed, 25 Feb 2026 23:05:22 +0000 Subject: [PATCH 01/18] chore(ledger): start task task-01 for issue #31 --- .agents/issue-31-ledger.yml | 523 ++++++++++++++++++++++++++++++++++++ 1 file changed, 523 insertions(+) create mode 100644 .agents/issue-31-ledger.yml diff --git a/.agents/issue-31-ledger.yml b/.agents/issue-31-ledger.yml new file mode 100644 index 00000000..64333d11 --- /dev/null +++ b/.agents/issue-31-ledger.yml @@ -0,0 +1,523 @@ +version: 1 +issue: 31 +base: main +branch: codex/issue-31 +tasks: + - id: task-01 + title: Create `src/counter_risk/outputs/base.py` defining an `OutputGenerator` + interface for output plugins. + status: doing + started_at: '2026-02-25T23:05:22Z' + finished_at: null + commit: '' + notes: [] + - id: task-02 + title: Update the historical workbook updater to implement `OutputGenerator`. + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-03 + title: 'Create a new class that wraps the historical workbook updater logic (verify: + confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-04 + title: 'implements the OutputGenerator interface (verify: confirm completion in + repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-05 + title: 'Define scope for: Refactor the existing historical workbook update code + to use the new OutputGenerator-based class (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-06 + title: 'Implement focused slice for: Refactor the existing historical workbook + update code to use the new OutputGenerator-based class (verify: confirm completion + in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-07 + title: 'Validate focused slice for: Refactor the existing historical workbook + update code to use the new OutputGenerator-based class (verify: confirm completion + in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-08 + title: 'Define scope for: Add unit tests to verify the historical workbook OutputGenerator + produces the same output as before' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-09 + title: 'Implement focused slice for: Add unit tests to verify the historical workbook + OutputGenerator produces the same output as before' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-10 + title: 'Validate focused slice for: Add unit tests to verify the historical workbook + OutputGenerator produces the same output as before' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-11 + title: 'Define scope for: Update any calling code to use the new OutputGenerator + interface for the historical workbook (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-12 + title: 'Implement focused slice for: Update any calling code to use the new OutputGenerator + interface for the historical workbook (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-13 + title: 'Validate focused slice for: Update any calling code to use the new OutputGenerator + interface for the historical workbook (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-14 + title: Update the PPT screenshot updater to implement `OutputGenerator`. + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-15 + title: 'Create a new class that wraps the PPT screenshot updater logic (verify: + confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-16 + title: 'Define scope for: Refactor the existing PPT screenshot update code to + use the new OutputGenerator-based class (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-17 + title: 'Implement focused slice for: Refactor the existing PPT screenshot update + code to use the new OutputGenerator-based class (verify: confirm completion + in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-18 + title: 'Validate focused slice for: Refactor the existing PPT screenshot update + code to use the new OutputGenerator-based class (verify: confirm completion + in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-19 + title: 'Define scope for: Add unit tests to verify the PPT screenshot OutputGenerator + produces the same output as before' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-20 + title: 'Implement focused slice for: Add unit tests to verify the PPT screenshot + OutputGenerator produces the same output as before' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-21 + title: 'Validate focused slice for: Add unit tests to verify the PPT screenshot + OutputGenerator produces the same output as before' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-22 + title: 'Define scope for: Update any calling code to use the new OutputGenerator + interface for the PPT screenshot updater (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-23 + title: 'Implement focused slice for: Update any calling code to use the new OutputGenerator + interface for the PPT screenshot updater (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-24 + title: 'Validate focused slice for: Update any calling code to use the new OutputGenerator + interface for the PPT screenshot updater (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-25 + title: Update the PPT link refresher to implement `OutputGenerator`. + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-26 + title: 'Create a new class that wraps the PPT link refresher logic (verify: confirm + completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-27 + title: 'Define scope for: Refactor the existing PPT link refresh code to use the + new OutputGenerator-based class (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-28 + title: 'Implement focused slice for: Refactor the existing PPT link refresh code + to use the new OutputGenerator-based class (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-29 + title: 'Validate focused slice for: Refactor the existing PPT link refresh code + to use the new OutputGenerator-based class (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-30 + title: 'Define scope for: Add unit tests to verify the PPT link refresher OutputGenerator + produces the same output as before' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-31 + title: 'Implement focused slice for: Add unit tests to verify the PPT link refresher + OutputGenerator produces the same output as before' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-32 + title: 'Validate focused slice for: Add unit tests to verify the PPT link refresher + OutputGenerator produces the same output as before' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-33 + title: 'Define scope for: Update any calling code to use the new OutputGenerator + interface for the PPT link refresher (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-34 + title: 'Implement focused slice for: Update any calling code to use the new OutputGenerator + interface for the PPT link refresher (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-35 + title: 'Validate focused slice for: Update any calling code to use the new OutputGenerator + interface for the PPT link refresher (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-36 + title: Add a PDF export generator that uses PowerPoint export via COM if available; + otherwise skips with a warning. + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-37 + title: 'Create a PDFExportGenerator class that implements the OutputGenerator + interface with method stubs (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-38 + title: 'Define scope for: Implement COM availability detection logic to check + if PowerPoint COM interface is accessible (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-39 + title: 'Implement focused slice for: Implement COM availability detection logic + to check if PowerPoint COM interface is accessible (verify: confirm completion + in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-40 + title: 'Validate focused slice for: Implement COM availability detection logic + to check if PowerPoint COM interface is accessible (verify: confirm completion + in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-41 + title: 'Add PowerPoint to PDF export functionality using COM automation when available + (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-42 + title: 'Implement graceful fallback behavior that logs a warning (verify: confirm + completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-43 + title: 'skips PDF generation when COM is unavailable (verify: confirm completion + in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-44 + title: 'Add unit tests for the PDF export generator covering both COM available + (verify: tests pass)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-45 + title: 'unavailable scenarios (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-46 + title: Add configuration to enable/disable specific outputs per run and register + output generators via config. + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-47 + title: 'Define configuration schema for output generators including enable/disable + flags (verify: config validated)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-48 + title: 'Define configuration schema for output generators including registration + details (verify: config validated)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-49 + title: 'Implement a registry mechanism that loads (verify: confirm completion + in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-50 + title: 'stores OutputGenerator instances from configuration (verify: config validated)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-51 + title: 'Add runtime logic to filter enabled output generators based on configuration + settings (verify: config validated)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-52 + title: 'Update configuration documentation to explain how to register (verify: + docs updated)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-53 + title: 'enable/disable output generators (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-54 + title: Add integration tests that verify outputs can be selectively enabled + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-55 + title: 'disabled via configuration (verify: config validated)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-56 + title: Implementing a single `OutputGenerator` class and registering it in config + adds a new output without requiring changes to other outputs. + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-57 + title: Disabling an output via config does not break the pipeline run. + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-58 + title: Add `src/counter_risk/outputs/base.py` with an `OutputGenerator` interface + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-59 + title: 'Implement existing outputs as generators:' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-60 + title: Historical workbook updater + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-61 + title: PPT screenshot updater + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-62 + title: PPT link refresher + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-63 + title: Add a PDF export generator (PowerPoint export via COM if available; otherwise + skip with warning) + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-64 + title: Add config to enable/disable outputs per run + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-65 + title: New outputs can be added by implementing one class and registering it in + config + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-66 + title: Disabling an output does not break the pipeline + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] From b4e30d1ca476e70a387be49a387d645f76e49277 Mon Sep 17 00:00:00 2001 From: stranske-automation-bot Date: Wed, 25 Feb 2026 23:05:35 +0000 Subject: [PATCH 02/18] chore(ledger): finish task task-01 for issue #31 --- .agents/issue-31-ledger.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/issue-31-ledger.yml b/.agents/issue-31-ledger.yml index 64333d11..e8eca592 100644 --- a/.agents/issue-31-ledger.yml +++ b/.agents/issue-31-ledger.yml @@ -6,10 +6,10 @@ tasks: - id: task-01 title: Create `src/counter_risk/outputs/base.py` defining an `OutputGenerator` interface for output plugins. - status: doing + status: done started_at: '2026-02-25T23:05:22Z' - finished_at: null - commit: '' + finished_at: '2026-02-25T23:05:35Z' + commit: ddb4281d14cbddad5333686624dd9db7adcb299f notes: [] - id: task-02 title: Update the historical workbook updater to implement `OutputGenerator`. From efed6075009d00ac10be91d778d5db83e3033bfd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:14:13 +0000 Subject: [PATCH 03/18] chore(codex-keepalive): apply updates (PR #256) --- src/counter_risk/outputs/__init__.py | 5 +++ src/counter_risk/outputs/base.py | 30 ++++++++++++++++ tests/outputs/test_base.py | 51 ++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 src/counter_risk/outputs/__init__.py create mode 100644 src/counter_risk/outputs/base.py create mode 100644 tests/outputs/test_base.py diff --git a/src/counter_risk/outputs/__init__.py b/src/counter_risk/outputs/__init__.py new file mode 100644 index 00000000..91e1379a --- /dev/null +++ b/src/counter_risk/outputs/__init__.py @@ -0,0 +1,5 @@ +"""Pluggable output generator interfaces and implementations.""" + +from .base import OutputContext, OutputGenerator + +__all__ = ["OutputContext", "OutputGenerator"] diff --git a/src/counter_risk/outputs/base.py b/src/counter_risk/outputs/base.py new file mode 100644 index 00000000..04bfb013 --- /dev/null +++ b/src/counter_risk/outputs/base.py @@ -0,0 +1,30 @@ +"""Output generator interface for pluggable pipeline outputs.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date +from pathlib import Path +from typing import Protocol + +from counter_risk.config import WorkflowConfig + + +@dataclass(frozen=True) +class OutputContext: + """Immutable run context passed to output generators.""" + + config: WorkflowConfig + run_dir: Path + as_of_date: date + run_date: date + warnings: tuple[str, ...] = field(default_factory=tuple) + + +class OutputGenerator(Protocol): + """Interface implemented by output plugins.""" + + name: str + + def generate(self, *, context: OutputContext) -> tuple[Path, ...]: + """Generate one output type and return created output paths.""" diff --git a/tests/outputs/test_base.py b/tests/outputs/test_base.py new file mode 100644 index 00000000..e5b6cade --- /dev/null +++ b/tests/outputs/test_base.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import date +from pathlib import Path + +from counter_risk.config import WorkflowConfig +from counter_risk.outputs.base import OutputContext, OutputGenerator + + +class _DummyGenerator: + name = "dummy" + + def generate(self, *, context: OutputContext) -> tuple[Path, ...]: + return (context.run_dir / "dummy.txt",) + + +def _minimal_config(tmp_path: Path) -> WorkflowConfig: + for filename in ( + "all.xlsx", + "ex.xlsx", + "trend.xlsx", + "hist_all.xlsx", + "hist_ex.xlsx", + "hist_llc.xlsx", + "monthly.pptx", + ): + (tmp_path / filename).write_bytes(b"placeholder") + + return WorkflowConfig( + mosers_all_programs_xlsx=tmp_path / "all.xlsx", + mosers_ex_trend_xlsx=tmp_path / "ex.xlsx", + mosers_trend_xlsx=tmp_path / "trend.xlsx", + hist_all_programs_3yr_xlsx=tmp_path / "hist_all.xlsx", + hist_ex_llc_3yr_xlsx=tmp_path / "hist_ex.xlsx", + hist_llc_3yr_xlsx=tmp_path / "hist_llc.xlsx", + monthly_pptx=tmp_path / "monthly.pptx", + ) + + +def test_output_generator_protocol_contract(tmp_path: Path) -> None: + config = _minimal_config(tmp_path) + context = OutputContext( + config=config, + run_dir=tmp_path / "run", + as_of_date=date(2026, 2, 25), + run_date=date(2026, 2, 25), + ) + + generator: OutputGenerator = _DummyGenerator() + assert generator.name == "dummy" + assert generator.generate(context=context) == (tmp_path / "run" / "dummy.txt",) From 8ceb1579d1784d92de09fd121293178180e08a84 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 25 Feb 2026 23:30:14 +0000 Subject: [PATCH 04/18] Add historical WAL output generator plugin --- src/counter_risk/outputs/__init__.py | 3 +- .../outputs/historical_workbook.py | 46 ++++++++ tests/outputs/test_historical_workbook.py | 110 ++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 src/counter_risk/outputs/historical_workbook.py create mode 100644 tests/outputs/test_historical_workbook.py diff --git a/src/counter_risk/outputs/__init__.py b/src/counter_risk/outputs/__init__.py index 91e1379a..507fc7fd 100644 --- a/src/counter_risk/outputs/__init__.py +++ b/src/counter_risk/outputs/__init__.py @@ -1,5 +1,6 @@ """Pluggable output generator interfaces and implementations.""" from .base import OutputContext, OutputGenerator +from .historical_workbook import HistoricalWalWorkbookOutputGenerator -__all__ = ["OutputContext", "OutputGenerator"] +__all__ = ["HistoricalWalWorkbookOutputGenerator", "OutputContext", "OutputGenerator"] diff --git a/src/counter_risk/outputs/historical_workbook.py b/src/counter_risk/outputs/historical_workbook.py new file mode 100644 index 00000000..2c3e7079 --- /dev/null +++ b/src/counter_risk/outputs/historical_workbook.py @@ -0,0 +1,46 @@ +"""Output generators for historical workbook updates.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from pathlib import Path +from typing import Protocol + +from counter_risk.calculations.wal import calculate_wal +from counter_risk.outputs.base import OutputContext, OutputGenerator +from counter_risk.writers.historical_update import append_wal_row, locate_ex_llc_3_year_workbook + + +class _WorkbookLocator(Protocol): + def __call__(self) -> Path: ... + + +class _WalCalculator(Protocol): + def __call__(self, exposure_summary_path: Path, px_date: date) -> float: ... + + +class _WalAppender(Protocol): + def __call__(self, workbook_path: str | Path, *, px_date: date, wal_value: float) -> Path: ... + + +@dataclass(frozen=True) +class HistoricalWalWorkbookOutputGenerator(OutputGenerator): + """Generate the historical WAL workbook update output.""" + + exposure_summary_path: Path + name: str = "historical_wal_workbook" + workbook_path: Path | None = None + workbook_locator: _WorkbookLocator = locate_ex_llc_3_year_workbook + wal_calculator: _WalCalculator = calculate_wal + wal_appender: _WalAppender = append_wal_row + + def generate(self, *, context: OutputContext) -> tuple[Path, ...]: + workbook_path = self.workbook_path or self.workbook_locator() + wal_value = self.wal_calculator(self.exposure_summary_path, context.as_of_date) + updated_workbook = self.wal_appender( + workbook_path, + px_date=context.as_of_date, + wal_value=wal_value, + ) + return (updated_workbook,) diff --git a/tests/outputs/test_historical_workbook.py b/tests/outputs/test_historical_workbook.py new file mode 100644 index 00000000..bbf5933d --- /dev/null +++ b/tests/outputs/test_historical_workbook.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from datetime import date +from pathlib import Path + +from counter_risk.config import WorkflowConfig +from counter_risk.outputs import HistoricalWalWorkbookOutputGenerator, OutputContext + + +def _minimal_config(tmp_path: Path) -> WorkflowConfig: + for filename in ( + "all.xlsx", + "ex.xlsx", + "trend.xlsx", + "hist_all.xlsx", + "hist_ex.xlsx", + "hist_llc.xlsx", + "monthly.pptx", + ): + (tmp_path / filename).write_bytes(b"placeholder") + + return WorkflowConfig( + mosers_all_programs_xlsx=tmp_path / "all.xlsx", + mosers_ex_trend_xlsx=tmp_path / "ex.xlsx", + mosers_trend_xlsx=tmp_path / "trend.xlsx", + hist_all_programs_3yr_xlsx=tmp_path / "hist_all.xlsx", + hist_ex_llc_3yr_xlsx=tmp_path / "hist_ex.xlsx", + hist_llc_3yr_xlsx=tmp_path / "hist_llc.xlsx", + monthly_pptx=tmp_path / "monthly.pptx", + ) + + +def _output_context(tmp_path: Path) -> OutputContext: + return OutputContext( + config=_minimal_config(tmp_path), + run_dir=tmp_path / "run", + as_of_date=date(2026, 1, 31), + run_date=date(2026, 2, 1), + ) + + +def test_historical_wal_generator_delegates_to_existing_wal_update_flow(tmp_path: Path) -> None: + exposure_summary_path = tmp_path / "exposure-summary.xlsx" + exposure_summary_path.write_bytes(b"placeholder") + workbook_path = tmp_path / "historical.xlsx" + workbook_path.write_bytes(b"placeholder") + calls: list[tuple[str, object, object]] = [] + + def _fake_locate() -> Path: + calls.append(("locate", None, None)) + return workbook_path + + def _fake_calculate(path: Path, px_date: date) -> float: + calls.append(("calculate_wal", path, px_date)) + return 2.718 + + def _fake_append(path: str | Path, *, px_date: date, wal_value: float) -> Path: + calls.append(("append_wal_row", Path(path), (px_date, wal_value))) + return Path(path) + + generator = HistoricalWalWorkbookOutputGenerator( + exposure_summary_path=exposure_summary_path, + workbook_locator=_fake_locate, + wal_calculator=_fake_calculate, + wal_appender=_fake_append, + ) + + generated = generator.generate(context=_output_context(tmp_path)) + + assert generator.name == "historical_wal_workbook" + assert generated == (workbook_path,) + assert calls == [ + ("locate", None, None), + ("calculate_wal", exposure_summary_path, date(2026, 1, 31)), + ("append_wal_row", workbook_path, (date(2026, 1, 31), 2.718)), + ] + + +def test_historical_wal_generator_uses_explicit_workbook_path_when_provided(tmp_path: Path) -> None: + exposure_summary_path = tmp_path / "exposure-summary.xlsx" + exposure_summary_path.write_bytes(b"placeholder") + workbook_path = tmp_path / "historical.xlsx" + workbook_path.write_bytes(b"placeholder") + + locate_calls: list[str] = [] + + def _fake_locate() -> Path: + locate_calls.append("called") + return tmp_path / "unexpected.xlsx" + + def _fake_calculate(path: Path, px_date: date) -> float: + del path, px_date + return 1.23 + + def _fake_append(path: str | Path, *, px_date: date, wal_value: float) -> Path: + del px_date, wal_value + return Path(path) + + generator = HistoricalWalWorkbookOutputGenerator( + exposure_summary_path=exposure_summary_path, + workbook_path=workbook_path, + workbook_locator=_fake_locate, + wal_calculator=_fake_calculate, + wal_appender=_fake_append, + ) + + generated = generator.generate(context=_output_context(tmp_path)) + + assert generated == (workbook_path,) + assert locate_calls == [] From 40a6138f45f31d037ab2efe0c7b6c04c4b9a76a5 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 25 Feb 2026 23:53:20 +0000 Subject: [PATCH 05/18] Add historical workbook output generator wrapper --- src/counter_risk/outputs/__init__.py | 12 +++- .../outputs/historical_workbook.py | 65 ++++++++++++++++- tests/outputs/test_historical_workbook.py | 72 ++++++++++++++++++- tests/test_runner_launch.py | 6 +- 4 files changed, 148 insertions(+), 7 deletions(-) diff --git a/src/counter_risk/outputs/__init__.py b/src/counter_risk/outputs/__init__.py index 507fc7fd..fa1fc013 100644 --- a/src/counter_risk/outputs/__init__.py +++ b/src/counter_risk/outputs/__init__.py @@ -1,6 +1,14 @@ """Pluggable output generator interfaces and implementations.""" from .base import OutputContext, OutputGenerator -from .historical_workbook import HistoricalWalWorkbookOutputGenerator +from .historical_workbook import ( + HistoricalWalWorkbookOutputGenerator, + HistoricalWorkbookOutputGenerator, +) -__all__ = ["HistoricalWalWorkbookOutputGenerator", "OutputContext", "OutputGenerator"] +__all__ = [ + "HistoricalWalWorkbookOutputGenerator", + "HistoricalWorkbookOutputGenerator", + "OutputContext", + "OutputGenerator", +] diff --git a/src/counter_risk/outputs/historical_workbook.py b/src/counter_risk/outputs/historical_workbook.py index 2c3e7079..1f7bc317 100644 --- a/src/counter_risk/outputs/historical_workbook.py +++ b/src/counter_risk/outputs/historical_workbook.py @@ -2,13 +2,15 @@ from __future__ import annotations +import shutil from dataclasses import dataclass from datetime import date from pathlib import Path -from typing import Protocol +from typing import Any, Mapping, Protocol from counter_risk.calculations.wal import calculate_wal from counter_risk.outputs.base import OutputContext, OutputGenerator +from counter_risk.pipeline.run import _merge_historical_workbook, _records from counter_risk.writers.historical_update import append_wal_row, locate_ex_llc_3_year_workbook @@ -24,6 +26,67 @@ class _WalAppender(Protocol): def __call__(self, workbook_path: str | Path, *, px_date: date, wal_value: float) -> Path: ... +class _WorkbookCopier(Protocol): + def __call__( + self, src: str | Path, dst: str | Path, *, follow_symlinks: bool = True + ) -> str: ... + + +class _HistoricalWorkbookMerger(Protocol): + def __call__( + self, + *, + workbook_path: Path, + variant: str, + as_of_date: date, + totals_records: list[dict[str, Any]], + warnings: list[str], + ) -> None: ... + + +class _RecordsExtractor(Protocol): + def __call__(self, table: Any) -> list[dict[str, Any]]: ... + + +@dataclass(frozen=True) +class HistoricalWorkbookOutputGenerator(OutputGenerator): + """Generate historical workbook outputs for all reporting variants.""" + + parsed_by_variant: Mapping[str, Mapping[str, Any]] + warnings: list[str] + name: str = "historical_workbook" + workbook_copier: _WorkbookCopier = shutil.copy2 + workbook_merger: _HistoricalWorkbookMerger = _merge_historical_workbook + records_extractor: _RecordsExtractor = _records + + def generate(self, *, context: OutputContext) -> tuple[Path, ...]: + mosers_all_programs = context.config.mosers_all_programs_xlsx + if mosers_all_programs is None: + raise ValueError("mosers_all_programs_xlsx is required for pipeline execution") + + variant_inputs = ( + ("all_programs", mosers_all_programs, context.config.hist_all_programs_3yr_xlsx), + ("ex_trend", context.config.mosers_ex_trend_xlsx, context.config.hist_ex_llc_3yr_xlsx), + ("trend", context.config.mosers_trend_xlsx, context.config.hist_llc_3yr_xlsx), + ) + + output_paths: list[Path] = [] + for variant, _workbook_path, historical_path in variant_inputs: + target_hist = context.run_dir / historical_path.name + self.workbook_copier(historical_path, target_hist) + totals_records = self.records_extractor(self.parsed_by_variant[variant]["totals"]) + self.workbook_merger( + workbook_path=target_hist, + variant=variant, + as_of_date=context.as_of_date, + totals_records=totals_records, + warnings=self.warnings, + ) + output_paths.append(target_hist) + + return tuple(output_paths) + + @dataclass(frozen=True) class HistoricalWalWorkbookOutputGenerator(OutputGenerator): """Generate the historical WAL workbook update output.""" diff --git a/tests/outputs/test_historical_workbook.py b/tests/outputs/test_historical_workbook.py index bbf5933d..6bc053e5 100644 --- a/tests/outputs/test_historical_workbook.py +++ b/tests/outputs/test_historical_workbook.py @@ -2,9 +2,14 @@ from datetime import date from pathlib import Path +from typing import Any from counter_risk.config import WorkflowConfig -from counter_risk.outputs import HistoricalWalWorkbookOutputGenerator, OutputContext +from counter_risk.outputs import ( + HistoricalWalWorkbookOutputGenerator, + HistoricalWorkbookOutputGenerator, + OutputContext, +) def _minimal_config(tmp_path: Path) -> WorkflowConfig: @@ -108,3 +113,68 @@ def _fake_append(path: str | Path, *, px_date: date, wal_value: float) -> Path: assert generated == (workbook_path,) assert locate_calls == [] + + +def test_historical_workbook_generator_wraps_pipeline_historical_update_flow( + tmp_path: Path, +) -> None: + run_dir = tmp_path / "run" + run_dir.mkdir() + output_context = _output_context(tmp_path) + warnings: list[str] = [] + copied: list[tuple[Path, Path]] = [] + merged: list[tuple[Path, str, float, int]] = [] + expected_outputs = ( + run_dir / "hist_all.xlsx", + run_dir / "hist_ex.xlsx", + run_dir / "hist_llc.xlsx", + ) + + parsed_by_variant: dict[str, dict[str, object]] = { + "all_programs": {"totals": [{"Notional": 100.0}]}, + "ex_trend": {"totals": [{"Notional": 200.0}]}, + "trend": {"totals": [{"Notional": 300.0}]}, + } + + def _fake_copy(src: str | Path, dst: str | Path, *, follow_symlinks: bool = True) -> str: + del follow_symlinks + source = Path(src) + target = Path(dst) + copied.append((source, target)) + target.write_bytes(source.read_bytes()) + return str(target) + + def _fake_records(table: Any) -> list[dict[str, object]]: + return [dict(record) for record in table] + + def _fake_merge( + *, + workbook_path: Path, + variant: str, + as_of_date: date, + totals_records: list[dict[str, object]], + warnings: list[str], + ) -> None: + del warnings + merged.append( + (workbook_path, variant, float(totals_records[0]["Notional"]), as_of_date.month) + ) + + generator = HistoricalWorkbookOutputGenerator( + parsed_by_variant=parsed_by_variant, + warnings=warnings, + workbook_copier=_fake_copy, + records_extractor=_fake_records, + workbook_merger=_fake_merge, + ) + + generated = generator.generate(context=output_context) + + assert generator.name == "historical_workbook" + assert generated == expected_outputs + assert [target for _, target in copied] == list(expected_outputs) + assert merged == [ + (expected_outputs[0], "all_programs", 100.0, 1), + (expected_outputs[1], "ex_trend", 200.0, 1), + (expected_outputs[2], "trend", 300.0, 1), + ] diff --git a/tests/test_runner_launch.py b/tests/test_runner_launch.py index 6335e7de..6b208227 100644 --- a/tests/test_runner_launch.py +++ b/tests/test_runner_launch.py @@ -17,9 +17,9 @@ @pytest.fixture -def filesystem_and_explorer_stubs() -> tuple[ - set[str], list[str], Callable[[str], bool], Callable[[str], int] -]: +def filesystem_and_explorer_stubs() -> ( + tuple[set[str], list[str], Callable[[str], bool], Callable[[str], int]] +): existing_directories: set[str] = set() opened_directories: list[str] = [] From 7c1fe85aff4228b5850025b98b278e5f4d78270b Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 26 Feb 2026 00:12:03 +0000 Subject: [PATCH 06/18] Refactor historical update flow to use output generator --- src/counter_risk/pipeline/run.py | 57 ++++++-------- tests/test_historical_workbook_validation.py | 80 ++++++++++++++++++++ 2 files changed, 103 insertions(+), 34 deletions(-) diff --git a/src/counter_risk/pipeline/run.py b/src/counter_risk/pipeline/run.py index 54f42470..941575d7 100644 --- a/src/counter_risk/pipeline/run.py +++ b/src/counter_risk/pipeline/run.py @@ -19,6 +19,7 @@ from counter_risk.dates import derive_as_of_date, derive_run_date from counter_risk.normalize import canonicalize_name, normalize_counterparty, resolve_counterparty from counter_risk.parsers import parse_fcm_totals, parse_futures_detail +from counter_risk.outputs.base import OutputContext from counter_risk.pipeline.manifest import ManifestBuilder from counter_risk.pipeline.parsing_types import ( ParsedDataInvalidShapeError, @@ -1650,45 +1651,33 @@ def _update_historical_outputs( warnings: list[str], ) -> list[Path]: LOGGER.info("historical_update_start run_dir=%s as_of_date=%s", run_dir, as_of_date.isoformat()) - variant_inputs = [ - _VariantInputs( - name="all_programs", - workbook_path=_require_path( - config.mosers_all_programs_xlsx, field_name="mosers_all_programs_xlsx" - ), - historical_path=config.hist_all_programs_3yr_xlsx, - ), - _VariantInputs( - name="ex_trend", - workbook_path=config.mosers_ex_trend_xlsx, - historical_path=config.hist_ex_llc_3yr_xlsx, - ), - _VariantInputs( - name="trend", - workbook_path=config.mosers_trend_xlsx, - historical_path=config.hist_llc_3yr_xlsx, - ), - ] - - output_paths: list[Path] = [] - for variant_input in variant_inputs: - source_hist = variant_input.historical_path - target_hist = run_dir / source_hist.name - shutil.copy2(source_hist, target_hist) - totals_records = _records(parsed_by_variant[variant_input.name]["totals"]) - _merge_historical_workbook( - workbook_path=target_hist, - variant=variant_input.name, - as_of_date=as_of_date, - totals_records=totals_records, - warnings=warnings, - ) - output_paths.append(target_hist) + output_generator = _build_historical_workbook_output_generator( + parsed_by_variant=parsed_by_variant, + warnings=warnings, + ) + output_context = OutputContext( + config=config, + run_dir=run_dir, + as_of_date=as_of_date, + run_date=config.run_date or as_of_date, + warnings=tuple(warnings), + ) + output_paths = list(output_generator.generate(context=output_context)) LOGGER.info("historical_update_complete workbook_count=%s", len(output_paths)) return output_paths +def _build_historical_workbook_output_generator( + *, + parsed_by_variant: dict[str, dict[str, Any]], + warnings: list[str], +) -> Any: + from counter_risk.outputs.historical_workbook import HistoricalWorkbookOutputGenerator + + return HistoricalWorkbookOutputGenerator(parsed_by_variant=parsed_by_variant, warnings=warnings) + + def _merge_historical_workbook( *, workbook_path: Path, diff --git a/tests/test_historical_workbook_validation.py b/tests/test_historical_workbook_validation.py index 5c8e4358..0d9d83e0 100644 --- a/tests/test_historical_workbook_validation.py +++ b/tests/test_historical_workbook_validation.py @@ -319,3 +319,83 @@ def _load_workbook(*, filename: Path) -> _FakeWorkbook: appended_date = sheet_by_path[all_programs_copy].cell(row=3, column=1).value assert appended_date == date(2026, 2, 13) assert appended_date != config.run_date + + +def test_historical_update_delegates_to_output_generator( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + all_programs_input = tmp_path / "all_programs.xlsx" + ex_trend_input = tmp_path / "ex_trend.xlsx" + trend_input = tmp_path / "trend.xlsx" + monthly_pptx = tmp_path / "monthly.pptx" + hist_all = tmp_path / "hist_all.xlsx" + hist_ex = tmp_path / "hist_ex.xlsx" + hist_trend = tmp_path / "hist_trend.xlsx" + for file_path in ( + all_programs_input, + ex_trend_input, + trend_input, + monthly_pptx, + hist_all, + hist_ex, + hist_trend, + ): + file_path.write_bytes(b"placeholder") + + run_dir = tmp_path / "runs" / "2026-02-13" + run_dir.mkdir(parents=True) + config = WorkflowConfig( + mosers_all_programs_xlsx=all_programs_input, + mosers_ex_trend_xlsx=ex_trend_input, + mosers_trend_xlsx=trend_input, + hist_all_programs_3yr_xlsx=hist_all, + hist_ex_llc_3yr_xlsx=hist_ex, + hist_llc_3yr_xlsx=hist_trend, + monthly_pptx=monthly_pptx, + ) + warnings = ["warning 1"] + parsed_by_variant = { + "all_programs": {"totals": [{"counterparty": "A", "Notional": 10.0}], "futures": []}, + "ex_trend": {"totals": [{"counterparty": "B", "Notional": 4.0}], "futures": []}, + "trend": {"totals": [{"counterparty": "C", "Notional": 7.0}], "futures": []}, + } + generated_outputs = (run_dir / "hist_all.xlsx", run_dir / "hist_ex.xlsx") + captured: dict[str, Any] = {} + + class _FakeGenerator: + def __init__( + self, *, parsed_by_variant: dict[str, dict[str, Any]], warnings: list[str] + ) -> None: + captured["parsed_by_variant"] = parsed_by_variant + captured["warnings"] = warnings + + def generate(self, *, context: Any) -> tuple[Path, ...]: + captured["context"] = context + return generated_outputs + + monkeypatch.setattr( + run_module, + "_build_historical_workbook_output_generator", + lambda *, parsed_by_variant, warnings: _FakeGenerator( + parsed_by_variant=parsed_by_variant, warnings=warnings + ), + ) + + output_paths = run_module._update_historical_outputs( + run_dir=run_dir, + config=config, + parsed_by_variant=parsed_by_variant, + as_of_date=date(2026, 2, 13), + warnings=warnings, + ) + + assert output_paths == list(generated_outputs) + assert captured["parsed_by_variant"] is parsed_by_variant + assert captured["warnings"] is warnings + context = captured["context"] + assert context.config is config + assert context.run_dir == run_dir + assert context.as_of_date == date(2026, 2, 13) + assert context.run_date == date(2026, 2, 13) + assert context.warnings == tuple(warnings) From 70aaaef8fad14b1dd51e13c5e2252f1b8809c7b0 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 26 Feb 2026 00:38:25 +0000 Subject: [PATCH 07/18] fix: resolve CI failures --- .../outputs/historical_workbook.py | 63 +++++++++---------- src/counter_risk/pipeline/run.py | 2 +- tests/outputs/test_historical_workbook.py | 5 +- tests/test_mapping_diff_report_cli.py | 6 +- tests/test_normalization_registry_first.py | 5 +- 5 files changed, 41 insertions(+), 40 deletions(-) diff --git a/src/counter_risk/outputs/historical_workbook.py b/src/counter_risk/outputs/historical_workbook.py index 1f7bc317..2f691675 100644 --- a/src/counter_risk/outputs/historical_workbook.py +++ b/src/counter_risk/outputs/historical_workbook.py @@ -3,49 +3,46 @@ from __future__ import annotations import shutil +from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import date from pathlib import Path -from typing import Any, Mapping, Protocol +from typing import Any, cast from counter_risk.calculations.wal import calculate_wal from counter_risk.outputs.base import OutputContext, OutputGenerator -from counter_risk.pipeline.run import _merge_historical_workbook, _records from counter_risk.writers.historical_update import append_wal_row, locate_ex_llc_3_year_workbook +_WorkbookLocator = Callable[[], Path] +_WalCalculator = Callable[[Path, date], float] +_WalAppender = Callable[..., Path] +_WorkbookCopier = Callable[[str | Path, str | Path], str] +_HistoricalWorkbookMerger = Callable[..., None] +_RecordsExtractor = Callable[[Any], list[dict[str, Any]]] -class _WorkbookLocator(Protocol): - def __call__(self) -> Path: ... +def _default_workbook_merger( + *, + workbook_path: Path, + variant: str, + as_of_date: date, + totals_records: list[dict[str, Any]], + warnings: list[str], +) -> None: + from counter_risk.pipeline.run import _merge_historical_workbook + _merge_historical_workbook( + workbook_path=workbook_path, + variant=variant, + as_of_date=as_of_date, + totals_records=totals_records, + warnings=warnings, + ) -class _WalCalculator(Protocol): - def __call__(self, exposure_summary_path: Path, px_date: date) -> float: ... +def _default_records_extractor(table: Any) -> list[dict[str, Any]]: + from counter_risk.pipeline.run import _records -class _WalAppender(Protocol): - def __call__(self, workbook_path: str | Path, *, px_date: date, wal_value: float) -> Path: ... - - -class _WorkbookCopier(Protocol): - def __call__( - self, src: str | Path, dst: str | Path, *, follow_symlinks: bool = True - ) -> str: ... - - -class _HistoricalWorkbookMerger(Protocol): - def __call__( - self, - *, - workbook_path: Path, - variant: str, - as_of_date: date, - totals_records: list[dict[str, Any]], - warnings: list[str], - ) -> None: ... - - -class _RecordsExtractor(Protocol): - def __call__(self, table: Any) -> list[dict[str, Any]]: ... + return _records(table) @dataclass(frozen=True) @@ -55,9 +52,9 @@ class HistoricalWorkbookOutputGenerator(OutputGenerator): parsed_by_variant: Mapping[str, Mapping[str, Any]] warnings: list[str] name: str = "historical_workbook" - workbook_copier: _WorkbookCopier = shutil.copy2 - workbook_merger: _HistoricalWorkbookMerger = _merge_historical_workbook - records_extractor: _RecordsExtractor = _records + workbook_copier: _WorkbookCopier = cast(_WorkbookCopier, shutil.copy2) + workbook_merger: _HistoricalWorkbookMerger = _default_workbook_merger + records_extractor: _RecordsExtractor = _default_records_extractor def generate(self, *, context: OutputContext) -> tuple[Path, ...]: mosers_all_programs = context.config.mosers_all_programs_xlsx diff --git a/src/counter_risk/pipeline/run.py b/src/counter_risk/pipeline/run.py index 941575d7..7a2c7e65 100644 --- a/src/counter_risk/pipeline/run.py +++ b/src/counter_risk/pipeline/run.py @@ -18,8 +18,8 @@ from counter_risk.config import WorkflowConfig, load_config from counter_risk.dates import derive_as_of_date, derive_run_date from counter_risk.normalize import canonicalize_name, normalize_counterparty, resolve_counterparty -from counter_risk.parsers import parse_fcm_totals, parse_futures_detail from counter_risk.outputs.base import OutputContext +from counter_risk.parsers import parse_fcm_totals, parse_futures_detail from counter_risk.pipeline.manifest import ManifestBuilder from counter_risk.pipeline.parsing_types import ( ParsedDataInvalidShapeError, diff --git a/tests/outputs/test_historical_workbook.py b/tests/outputs/test_historical_workbook.py index 6bc053e5..e731a22f 100644 --- a/tests/outputs/test_historical_workbook.py +++ b/tests/outputs/test_historical_workbook.py @@ -2,7 +2,7 @@ from datetime import date from pathlib import Path -from typing import Any +from typing import Any, cast from counter_risk.config import WorkflowConfig from counter_risk.outputs import ( @@ -156,8 +156,9 @@ def _fake_merge( warnings: list[str], ) -> None: del warnings + notional = cast(float, totals_records[0]["Notional"]) merged.append( - (workbook_path, variant, float(totals_records[0]["Notional"]), as_of_date.month) + (workbook_path, variant, float(notional), as_of_date.month) ) generator = HistoricalWorkbookOutputGenerator( diff --git a/tests/test_mapping_diff_report_cli.py b/tests/test_mapping_diff_report_cli.py index f2988796..7a67296a 100644 --- a/tests/test_mapping_diff_report_cli.py +++ b/tests/test_mapping_diff_report_cli.py @@ -9,6 +9,8 @@ import sys from pathlib import Path +import pytest + from counter_risk.cli import mapping_diff_report @@ -165,7 +167,7 @@ def test_mapping_diff_report_with_fixture_inputs_contains_required_sections() -> def test_mapping_diff_report_forwards_registry_path_parameter( tmp_path: Path, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, ) -> None: captured_call: dict[str, object] = {} @@ -198,7 +200,7 @@ def _fake_generate_mapping_diff_report( def test_mapping_diff_report_forwards_output_format_parameter( tmp_path: Path, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, ) -> None: captured_call: dict[str, object] = {} diff --git a/tests/test_normalization_registry_first.py b/tests/test_normalization_registry_first.py index 7e99f4c9..5b5e977e 100644 --- a/tests/test_normalization_registry_first.py +++ b/tests/test_normalization_registry_first.py @@ -5,6 +5,7 @@ import logging import shutil from pathlib import Path +from typing import Any import pytest @@ -202,9 +203,9 @@ def _run_with_fixture(fixture_name: str, run_dir: Path) -> list[str]: monkeypatch.chdir(run_dir) captured_sources: list[str] = [] - original = pipeline_run.resolve_counterparty + original = getattr(pipeline_run, "resolve_counterparty") - def _capture_source(raw_name: str): + def _capture_source(raw_name: str) -> Any: resolution = original(raw_name) captured_sources.append(resolution.source) return resolution From 407f7de079b208b7c23c149ab76e647b011a5a12 Mon Sep 17 00:00:00 2001 From: stranske Date: Wed, 25 Feb 2026 18:51:53 -0600 Subject: [PATCH 08/18] fix: remove unused typing.Any import (lint F401) The merge conflict resolution in 073de24 left an unused `from typing import Any` in test_normalization_registry_first.py, causing the lint-ruff Gate check to fail. https://claude.ai/code/session_01JhCWWDJG8PqwaSbVPCGfm6 Co-authored-by: Claude --- tests/test_normalization_registry_first.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_normalization_registry_first.py b/tests/test_normalization_registry_first.py index 551988a4..e6e2be2e 100644 --- a/tests/test_normalization_registry_first.py +++ b/tests/test_normalization_registry_first.py @@ -5,7 +5,6 @@ import logging import shutil from pathlib import Path -from typing import Any import pytest From 929ededb71881e174f0c9d6ed211d04c2f3e68d4 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 26 Feb 2026 01:22:49 +0000 Subject: [PATCH 09/18] Refactor historical output generator dependency wiring --- .../outputs/historical_workbook.py | 28 ++----------------- src/counter_risk/pipeline/run.py | 11 ++++++-- 2 files changed, 10 insertions(+), 29 deletions(-) diff --git a/src/counter_risk/outputs/historical_workbook.py b/src/counter_risk/outputs/historical_workbook.py index 2f691675..521a113b 100644 --- a/src/counter_risk/outputs/historical_workbook.py +++ b/src/counter_risk/outputs/historical_workbook.py @@ -20,30 +20,6 @@ _HistoricalWorkbookMerger = Callable[..., None] _RecordsExtractor = Callable[[Any], list[dict[str, Any]]] -def _default_workbook_merger( - *, - workbook_path: Path, - variant: str, - as_of_date: date, - totals_records: list[dict[str, Any]], - warnings: list[str], -) -> None: - from counter_risk.pipeline.run import _merge_historical_workbook - - _merge_historical_workbook( - workbook_path=workbook_path, - variant=variant, - as_of_date=as_of_date, - totals_records=totals_records, - warnings=warnings, - ) - - -def _default_records_extractor(table: Any) -> list[dict[str, Any]]: - from counter_risk.pipeline.run import _records - - return _records(table) - @dataclass(frozen=True) class HistoricalWorkbookOutputGenerator(OutputGenerator): @@ -51,10 +27,10 @@ class HistoricalWorkbookOutputGenerator(OutputGenerator): parsed_by_variant: Mapping[str, Mapping[str, Any]] warnings: list[str] + workbook_merger: _HistoricalWorkbookMerger + records_extractor: _RecordsExtractor name: str = "historical_workbook" workbook_copier: _WorkbookCopier = cast(_WorkbookCopier, shutil.copy2) - workbook_merger: _HistoricalWorkbookMerger = _default_workbook_merger - records_extractor: _RecordsExtractor = _default_records_extractor def generate(self, *, context: OutputContext) -> tuple[Path, ...]: mosers_all_programs = context.config.mosers_all_programs_xlsx diff --git a/src/counter_risk/pipeline/run.py b/src/counter_risk/pipeline/run.py index 4aa79d74..3f8981f0 100644 --- a/src/counter_risk/pipeline/run.py +++ b/src/counter_risk/pipeline/run.py @@ -22,7 +22,7 @@ normalize_counterparty, normalize_counterparty_with_source, ) -from counter_risk.outputs.base import OutputContext +from counter_risk.outputs.base import OutputContext, OutputGenerator from counter_risk.parsers import parse_fcm_totals, parse_futures_detail from counter_risk.pipeline.manifest import ManifestBuilder from counter_risk.pipeline.parsing_types import ( @@ -1676,10 +1676,15 @@ def _build_historical_workbook_output_generator( *, parsed_by_variant: dict[str, dict[str, Any]], warnings: list[str], -) -> Any: +) -> OutputGenerator: from counter_risk.outputs.historical_workbook import HistoricalWorkbookOutputGenerator - return HistoricalWorkbookOutputGenerator(parsed_by_variant=parsed_by_variant, warnings=warnings) + return HistoricalWorkbookOutputGenerator( + parsed_by_variant=parsed_by_variant, + warnings=warnings, + workbook_merger=_merge_historical_workbook, + records_extractor=_records, + ) def _merge_historical_workbook( From 1a8f73d45cdcb06284293caf5e9a42fb0f480ffb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 02:24:46 +0000 Subject: [PATCH 10/18] chore(codex-keepalive): apply updates (PR #256) --- tests/test_historical_workbook_validation.py | 87 ++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/tests/test_historical_workbook_validation.py b/tests/test_historical_workbook_validation.py index 0d9d83e0..0ef7fa76 100644 --- a/tests/test_historical_workbook_validation.py +++ b/tests/test_historical_workbook_validation.py @@ -399,3 +399,90 @@ def generate(self, *, context: Any) -> tuple[Path, ...]: assert context.as_of_date == date(2026, 2, 13) assert context.run_date == date(2026, 2, 13) assert context.warnings == tuple(warnings) + + +def test_historical_output_generator_builder_preserves_variant_merge_contract( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + all_programs_input = tmp_path / "all_programs.xlsx" + ex_trend_input = tmp_path / "ex_trend.xlsx" + trend_input = tmp_path / "trend.xlsx" + monthly_pptx = tmp_path / "monthly.pptx" + hist_all = tmp_path / "hist_all.xlsx" + hist_ex = tmp_path / "hist_ex.xlsx" + hist_trend = tmp_path / "hist_trend.xlsx" + for file_path in ( + all_programs_input, + ex_trend_input, + trend_input, + monthly_pptx, + hist_all, + hist_ex, + hist_trend, + ): + file_path.write_bytes(b"placeholder") + + run_dir = tmp_path / "runs" / "2026-02-13" + run_dir.mkdir(parents=True) + config = WorkflowConfig( + mosers_all_programs_xlsx=all_programs_input, + mosers_ex_trend_xlsx=ex_trend_input, + mosers_trend_xlsx=trend_input, + hist_all_programs_3yr_xlsx=hist_all, + hist_ex_llc_3yr_xlsx=hist_ex, + hist_llc_3yr_xlsx=hist_trend, + monthly_pptx=monthly_pptx, + ) + + warnings: list[str] = [] + parsed_by_variant = { + "all_programs": {"totals": [{"counterparty": "A", "Notional": 10.0}, "skip-me"]}, + "ex_trend": {"totals": [{"counterparty": "B", "Notional": 4.0}]}, + "trend": {"totals": [{"counterparty": "C", "Notional": 7.0}]}, + } + + merge_calls: list[tuple[Path, str, list[dict[str, Any]], list[str]]] = [] + + def _fake_merge( + *, + workbook_path: Path, + variant: str, + as_of_date: date, + totals_records: list[dict[str, Any]], + warnings: list[str], + ) -> None: + assert as_of_date == date(2026, 2, 13) + merge_calls.append((workbook_path, variant, totals_records, warnings)) + + monkeypatch.setattr(run_module, "_merge_historical_workbook", _fake_merge) + generator = run_module._build_historical_workbook_output_generator( + parsed_by_variant=parsed_by_variant, + warnings=warnings, + ) + + output_paths = generator.generate( + context=run_module.OutputContext( + config=config, + run_dir=run_dir, + as_of_date=date(2026, 2, 13), + run_date=date(2026, 2, 13), + warnings=tuple(warnings), + ) + ) + + assert output_paths == ( + run_dir / hist_all.name, + run_dir / hist_ex.name, + run_dir / hist_trend.name, + ) + assert merge_calls == [ + ( + run_dir / hist_all.name, + "all_programs", + [{"counterparty": "A", "Notional": 10.0}], + warnings, + ), + (run_dir / hist_ex.name, "ex_trend", [{"counterparty": "B", "Notional": 4.0}], warnings), + (run_dir / hist_trend.name, "trend", [{"counterparty": "C", "Notional": 7.0}], warnings), + ] From 6d5f55dd57d6117e6cf9b172e5502106890b94db Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 26 Feb 2026 02:48:40 +0000 Subject: [PATCH 11/18] refactor: move PPT screenshot output to OutputGenerator --- src/counter_risk/outputs/__init__.py | 2 + src/counter_risk/outputs/ppt_screenshot.py | 62 +++++++++++ src/counter_risk/pipeline/run.py | 43 +++++--- tests/outputs/test_historical_workbook.py | 4 +- tests/outputs/test_ppt_screenshot.py | 120 +++++++++++++++++++++ 5 files changed, 211 insertions(+), 20 deletions(-) create mode 100644 src/counter_risk/outputs/ppt_screenshot.py create mode 100644 tests/outputs/test_ppt_screenshot.py diff --git a/src/counter_risk/outputs/__init__.py b/src/counter_risk/outputs/__init__.py index fa1fc013..be192e80 100644 --- a/src/counter_risk/outputs/__init__.py +++ b/src/counter_risk/outputs/__init__.py @@ -5,10 +5,12 @@ HistoricalWalWorkbookOutputGenerator, HistoricalWorkbookOutputGenerator, ) +from .ppt_screenshot import PptScreenshotOutputGenerator __all__ = [ "HistoricalWalWorkbookOutputGenerator", "HistoricalWorkbookOutputGenerator", "OutputContext", "OutputGenerator", + "PptScreenshotOutputGenerator", ] diff --git a/src/counter_risk/outputs/ppt_screenshot.py b/src/counter_risk/outputs/ppt_screenshot.py new file mode 100644 index 00000000..f77bc60b --- /dev/null +++ b/src/counter_risk/outputs/ppt_screenshot.py @@ -0,0 +1,62 @@ +"""Output generator for monthly PPT screenshot replacement.""" + +from __future__ import annotations + +import shutil +from collections.abc import Callable +from dataclasses import dataclass +from datetime import date +from pathlib import Path + +from counter_risk.config import WorkflowConfig +from counter_risk.outputs.base import OutputContext, OutputGenerator +from counter_risk.pipeline.ppt_naming import PptOutputNames, resolve_ppt_output_names + +ScreenshotReplacer = Callable[[Path, Path, dict[str, Path]], None] +_ScreenshotInputMappingResolver = Callable[[WorkflowConfig], dict[str, Path]] +_ScreenshotReplacerResolver = Callable[[str], ScreenshotReplacer] +_MasterLinkTargetValidator = Callable[..., None] +_PptOutputNamesResolver = Callable[[date], PptOutputNames] +_PptCopier = Callable[[str | Path, str | Path], str] + + +@dataclass(frozen=True) +class PptScreenshotOutputGenerator(OutputGenerator): + """Generate the Master PPT output with optional screenshot replacement.""" + + warnings: list[str] + screenshot_input_mapping_resolver: _ScreenshotInputMappingResolver + screenshot_replacer_resolver: _ScreenshotReplacerResolver + master_link_target_validator: _MasterLinkTargetValidator + name: str = "ppt_screenshot" + ppt_output_names_resolver: _PptOutputNamesResolver = resolve_ppt_output_names + ppt_copier: _PptCopier = shutil.copy2 + + def generate(self, *, context: OutputContext) -> tuple[Path, ...]: + config = context.config + if not config.ppt_output_enabled: + return () + + source_ppt = config.monthly_pptx + output_names = self.ppt_output_names_resolver(context.as_of_date) + target_master_ppt = context.run_dir / output_names.master_filename + target_master_ppt.parent.mkdir(parents=True, exist_ok=True) + screenshot_inputs = self.screenshot_input_mapping_resolver(config) + + if config.enable_screenshot_replacement: + replacer = self.screenshot_replacer_resolver( + config.screenshot_replacement_implementation + ) + replacer(source_ppt, target_master_ppt, screenshot_inputs) + else: + self.ppt_copier(source_ppt, target_master_ppt) + if screenshot_inputs: + self.warnings.append( + "PPT screenshots replacement disabled; copied source deck to Master unchanged" + ) + + self.master_link_target_validator( + source_pptx_path=source_ppt, + master_pptx_path=target_master_ppt, + ) + return (target_master_ppt,) diff --git a/src/counter_risk/pipeline/run.py b/src/counter_risk/pipeline/run.py index 3f8981f0..121d3520 100644 --- a/src/counter_risk/pipeline/run.py +++ b/src/counter_risk/pipeline/run.py @@ -918,29 +918,27 @@ def _write_outputs( LOGGER.info("write_outputs_skip_ppt run_dir=%s", run_dir) return output_paths, PptProcessingResult(status=PptProcessingStatus.SKIPPED) - source_ppt = config.monthly_pptx output_names = resolve_ppt_output_names(as_of_date) - target_master_ppt = run_dir / output_names.master_filename target_distribution_ppt = run_dir / output_names.distribution_filename + output_context = OutputContext( + config=config, + run_dir=run_dir, + as_of_date=as_of_date, + run_date=config.run_date or as_of_date, + warnings=tuple(warnings), + ) + screenshot_generator = _build_ppt_screenshot_output_generator(warnings=warnings) + master_output_paths = list(screenshot_generator.generate(context=output_context)) + if len(master_output_paths) != 1: + raise RuntimeError( + "PPT screenshot output generator must produce exactly one Master PPT output" + ) + target_master_ppt = master_output_paths[0] + output_paths.extend(master_output_paths) readme_ppt_outputs = RunFolderReadmePptOutputs( master=target_master_ppt.relative_to(run_dir), distribution=target_distribution_ppt.relative_to(run_dir), ) - screenshot_inputs = _resolve_screenshot_input_mapping(config) - if config.enable_screenshot_replacement: - replacer = _get_screenshot_replacer(config.screenshot_replacement_implementation) - replacer(source_ppt, target_master_ppt, screenshot_inputs) - else: - shutil.copy2(source_ppt, target_master_ppt) - if screenshot_inputs: - warnings.append( - "PPT screenshots replacement disabled; copied source deck to Master unchanged" - ) - _assert_master_preserves_external_link_targets( - source_pptx_path=source_ppt, - master_pptx_path=target_master_ppt, - ) - output_paths.append(target_master_ppt) try: refresh_result = _refresh_ppt_links(target_master_ppt) @@ -1687,6 +1685,17 @@ def _build_historical_workbook_output_generator( ) +def _build_ppt_screenshot_output_generator(*, warnings: list[str]) -> OutputGenerator: + from counter_risk.outputs.ppt_screenshot import PptScreenshotOutputGenerator + + return PptScreenshotOutputGenerator( + warnings=warnings, + screenshot_input_mapping_resolver=_resolve_screenshot_input_mapping, + screenshot_replacer_resolver=_get_screenshot_replacer, + master_link_target_validator=_assert_master_preserves_external_link_targets, + ) + + def _merge_historical_workbook( *, workbook_path: Path, diff --git a/tests/outputs/test_historical_workbook.py b/tests/outputs/test_historical_workbook.py index e731a22f..5cfec810 100644 --- a/tests/outputs/test_historical_workbook.py +++ b/tests/outputs/test_historical_workbook.py @@ -157,9 +157,7 @@ def _fake_merge( ) -> None: del warnings notional = cast(float, totals_records[0]["Notional"]) - merged.append( - (workbook_path, variant, float(notional), as_of_date.month) - ) + merged.append((workbook_path, variant, float(notional), as_of_date.month)) generator = HistoricalWorkbookOutputGenerator( parsed_by_variant=parsed_by_variant, diff --git a/tests/outputs/test_ppt_screenshot.py b/tests/outputs/test_ppt_screenshot.py new file mode 100644 index 00000000..11243ae4 --- /dev/null +++ b/tests/outputs/test_ppt_screenshot.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from datetime import date +from pathlib import Path +from typing import Callable + +from counter_risk.config import WorkflowConfig +from counter_risk.outputs import OutputContext, PptScreenshotOutputGenerator +from counter_risk.pipeline.ppt_naming import resolve_ppt_output_names + + +def _minimal_config(tmp_path: Path, *, enable_screenshot_replacement: bool) -> WorkflowConfig: + for filename in ( + "all.xlsx", + "ex.xlsx", + "trend.xlsx", + "hist_all.xlsx", + "hist_ex.xlsx", + "hist_llc.xlsx", + "monthly.pptx", + ): + (tmp_path / filename).write_bytes(b"placeholder") + + screenshot = tmp_path / "slide_1.png" + screenshot.write_bytes(b"png") + + return WorkflowConfig( + mosers_all_programs_xlsx=tmp_path / "all.xlsx", + mosers_ex_trend_xlsx=tmp_path / "ex.xlsx", + mosers_trend_xlsx=tmp_path / "trend.xlsx", + hist_all_programs_3yr_xlsx=tmp_path / "hist_all.xlsx", + hist_ex_llc_3yr_xlsx=tmp_path / "hist_ex.xlsx", + hist_llc_3yr_xlsx=tmp_path / "hist_llc.xlsx", + monthly_pptx=tmp_path / "monthly.pptx", + enable_screenshot_replacement=enable_screenshot_replacement, + screenshot_inputs={"slide1": screenshot}, + ) + + +def _output_context(tmp_path: Path, *, enable_screenshot_replacement: bool) -> OutputContext: + return OutputContext( + config=_minimal_config( + tmp_path, enable_screenshot_replacement=enable_screenshot_replacement + ), + run_dir=tmp_path / "run", + as_of_date=date(2026, 1, 31), + run_date=date(2026, 2, 1), + ) + + +def test_ppt_screenshot_generator_routes_to_replacer_when_enabled(tmp_path: Path) -> None: + context = _output_context(tmp_path, enable_screenshot_replacement=True) + warnings: list[str] = [] + calls: list[tuple[Path, Path, dict[str, Path]]] = [] + + def _mapping_resolver(_config: WorkflowConfig) -> dict[str, Path]: + return {"slide1": context.config.screenshot_inputs["slide1"]} + + def _replacer(source: Path, output: Path, mapping: dict[str, Path]) -> None: + calls.append((source, output, mapping)) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(source.read_bytes() + b"-replaced") + + def _replacer_resolver(_implementation: str) -> Callable[[Path, Path, dict[str, Path]], None]: + return _replacer + + def _validator(*, source_pptx_path: Path, master_pptx_path: Path) -> None: + assert source_pptx_path == context.config.monthly_pptx + assert master_pptx_path.read_bytes().endswith(b"-replaced") + + generator = PptScreenshotOutputGenerator( + warnings=warnings, + screenshot_input_mapping_resolver=_mapping_resolver, + screenshot_replacer_resolver=_replacer_resolver, + master_link_target_validator=_validator, + ) + + generated = generator.generate(context=context) + expected_output = context.run_dir / resolve_ppt_output_names(context.as_of_date).master_filename + + assert generated == (expected_output,) + assert calls == [ + ( + context.config.monthly_pptx, + expected_output, + {"slide1": context.config.screenshot_inputs["slide1"]}, + ) + ] + assert warnings == [] + + +def test_ppt_screenshot_generator_copies_source_when_replacement_disabled(tmp_path: Path) -> None: + context = _output_context(tmp_path, enable_screenshot_replacement=False) + warnings: list[str] = [] + + def _mapping_resolver(_config: WorkflowConfig) -> dict[str, Path]: + return {"slide1": context.config.screenshot_inputs["slide1"]} + + def _replacer_resolver(_implementation: str) -> Callable[[Path, Path, dict[str, Path]], None]: + raise AssertionError( + "Replacer should not be resolved when screenshot replacement is disabled" + ) + + def _validator(*, source_pptx_path: Path, master_pptx_path: Path) -> None: + assert master_pptx_path.read_bytes() == source_pptx_path.read_bytes() + + generator = PptScreenshotOutputGenerator( + warnings=warnings, + screenshot_input_mapping_resolver=_mapping_resolver, + screenshot_replacer_resolver=_replacer_resolver, + master_link_target_validator=_validator, + ) + + generated = generator.generate(context=context) + expected_output = context.run_dir / resolve_ppt_output_names(context.as_of_date).master_filename + + assert generated == (expected_output,) + assert warnings == [ + "PPT screenshots replacement disabled; copied source deck to Master unchanged" + ] From 955ae4e79a815908e62e4561e806cc3181c9137d Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 26 Feb 2026 03:01:23 +0000 Subject: [PATCH 12/18] Refactor PPT link refresh into output generator --- src/counter_risk/outputs/__init__.py | 8 ++ src/counter_risk/outputs/ppt_link_refresh.py | 107 +++++++++++++++++++ src/counter_risk/pipeline/run.py | 50 +++++---- tests/outputs/test_ppt_link_refresh.py | 104 ++++++++++++++++++ 4 files changed, 250 insertions(+), 19 deletions(-) create mode 100644 src/counter_risk/outputs/ppt_link_refresh.py create mode 100644 tests/outputs/test_ppt_link_refresh.py diff --git a/src/counter_risk/outputs/__init__.py b/src/counter_risk/outputs/__init__.py index be192e80..dacbfd95 100644 --- a/src/counter_risk/outputs/__init__.py +++ b/src/counter_risk/outputs/__init__.py @@ -5,6 +5,11 @@ HistoricalWalWorkbookOutputGenerator, HistoricalWorkbookOutputGenerator, ) +from .ppt_link_refresh import ( + PptLinkRefreshOutputGenerator, + PptLinkRefreshResult, + PptLinkRefreshStatus, +) from .ppt_screenshot import PptScreenshotOutputGenerator __all__ = [ @@ -12,5 +17,8 @@ "HistoricalWorkbookOutputGenerator", "OutputContext", "OutputGenerator", + "PptLinkRefreshOutputGenerator", + "PptLinkRefreshResult", + "PptLinkRefreshStatus", "PptScreenshotOutputGenerator", ] diff --git a/src/counter_risk/outputs/ppt_link_refresh.py b/src/counter_risk/outputs/ppt_link_refresh.py new file mode 100644 index 00000000..08d64eeb --- /dev/null +++ b/src/counter_risk/outputs/ppt_link_refresh.py @@ -0,0 +1,107 @@ +"""Output generator for Master PPT link refresh via COM automation.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path + +from counter_risk.outputs.base import OutputContext, OutputGenerator +from counter_risk.pipeline.ppt_naming import resolve_ppt_output_names + +LOGGER = logging.getLogger(__name__) + + +class PptLinkRefreshStatus(StrEnum): + """Machine-readable statuses for PPT link refresh.""" + + SUCCESS = "success" + SKIPPED = "skipped" + FAILED = "failed" + + +@dataclass(frozen=True) +class PptLinkRefreshResult: + """Result envelope for output-generator-driven link refresh.""" + + status: PptLinkRefreshStatus + error_detail: str | None = None + + +_PptLinkRefresher = Callable[[Path], object] +_PptLinkRefreshResultResolver = Callable[[object], PptLinkRefreshResult] + + +@dataclass(frozen=True) +class PptLinkRefreshOutputGenerator(OutputGenerator): + """Refresh linked content in the generated Master PPT.""" + + warnings: list[str] + ppt_link_refresher: _PptLinkRefresher + name: str = "ppt_link_refresh" + refresh_result_resolver: _PptLinkRefreshResultResolver | None = None + last_result: PptLinkRefreshResult | None = field(init=False, default=None) + + def __post_init__(self) -> None: + if self.refresh_result_resolver is None: + object.__setattr__(self, "refresh_result_resolver", resolve_ppt_link_refresh_result) + + def generate(self, *, context: OutputContext) -> tuple[Path, ...]: + if not context.config.ppt_output_enabled: + return () + + master_pptx_path = ( + context.run_dir / resolve_ppt_output_names(context.as_of_date).master_filename + ) + + try: + raw_result = self.ppt_link_refresher(master_pptx_path) + except Exception as exc: + LOGGER.error("Master PPT link refresh failed: %s", exc) + result = PptLinkRefreshResult( + status=PptLinkRefreshStatus.FAILED, + error_detail=str(exc), + ) + else: + result = self.refresh_result_resolver(raw_result) + + object.__setattr__(self, "last_result", result) + + if result.status == PptLinkRefreshStatus.SKIPPED: + self.warnings.append("PPT links not refreshed; COM refresh skipped") + if result.status == PptLinkRefreshStatus.FAILED: + self.warnings.append( + "PPT links refresh failed; COM refresh encountered an error" + if not result.error_detail + else f"PPT links refresh failed; {result.error_detail}" + ) + + return () + + +def resolve_ppt_link_refresh_result(raw_result: object) -> PptLinkRefreshResult: + """Normalize legacy refresh return values into generator result envelopes.""" + + if isinstance(raw_result, bool): + return PptLinkRefreshResult( + status=PptLinkRefreshStatus.SUCCESS if raw_result else PptLinkRefreshStatus.SKIPPED + ) + + status_value = getattr(raw_result, "status", None) + error_detail = getattr(raw_result, "error_detail", None) + + if status_value is None: + raise TypeError( + "PPT link refresh result must be a bool or object with a 'status' attribute" + ) + + status_text = str(getattr(status_value, "value", status_value)).strip().lower() + try: + normalized_status = PptLinkRefreshStatus(status_text) + except ValueError as exc: + raise ValueError(f"Unsupported PPT link refresh status: {status_text!r}") from exc + + normalized_error = None if error_detail is None else str(error_detail) + return PptLinkRefreshResult(status=normalized_status, error_detail=normalized_error) diff --git a/src/counter_risk/pipeline/run.py b/src/counter_risk/pipeline/run.py index 121d3520..40ee715b 100644 --- a/src/counter_risk/pipeline/run.py +++ b/src/counter_risk/pipeline/run.py @@ -940,27 +940,11 @@ def _write_outputs( distribution=target_distribution_ppt.relative_to(run_dir), ) - try: - refresh_result = _refresh_ppt_links(target_master_ppt) - except Exception as exc: - LOGGER.error("Master PPT link refresh failed: %s", exc) - refresh_result = PptProcessingResult( - status=PptProcessingStatus.FAILED, - error_detail=str(exc), - ) - if isinstance(refresh_result, bool): - refresh_result = PptProcessingResult( - status=PptProcessingStatus.SUCCESS if refresh_result else PptProcessingStatus.SKIPPED - ) + link_refresh_generator = _build_ppt_link_refresh_output_generator(warnings=warnings) + link_refresh_generator.generate(context=output_context) + refresh_result = _to_ppt_processing_result(link_refresh_generator.last_result) - if refresh_result.status == PptProcessingStatus.SKIPPED: - warnings.append("PPT links not refreshed; COM refresh skipped") if refresh_result.status == PptProcessingStatus.FAILED: - warnings.append( - "PPT links refresh failed; COM refresh encountered an error" - if not refresh_result.error_detail - else f"PPT links refresh failed; {refresh_result.error_detail}" - ) LOGGER.warning( "Skipping distribution PPT derivation because Master PPT refresh failed: %s", target_master_ppt, @@ -1696,6 +1680,34 @@ def _build_ppt_screenshot_output_generator(*, warnings: list[str]) -> OutputGene ) +def _build_ppt_link_refresh_output_generator(*, warnings: list[str]): + from counter_risk.outputs.ppt_link_refresh import PptLinkRefreshOutputGenerator + + return PptLinkRefreshOutputGenerator( + warnings=warnings, + ppt_link_refresher=_refresh_ppt_links, + ) + + +def _to_ppt_processing_result(refresh_result: object | None) -> PptProcessingResult: + if refresh_result is None: + raise RuntimeError("PPT link refresh output generator did not record a refresh result") + + status_value = getattr(refresh_result, "status", None) + if status_value is None: + raise TypeError("PPT link refresh result is missing a status") + + status_text = str(getattr(status_value, "value", status_value)).strip().lower() + try: + status = PptProcessingStatus(status_text) + except ValueError as exc: + raise ValueError(f"Unsupported PPT processing status: {status_text!r}") from exc + + error_detail = getattr(refresh_result, "error_detail", None) + normalized_error = None if error_detail is None else str(error_detail) + return PptProcessingResult(status=status, error_detail=normalized_error) + + def _merge_historical_workbook( *, workbook_path: Path, diff --git a/tests/outputs/test_ppt_link_refresh.py b/tests/outputs/test_ppt_link_refresh.py new file mode 100644 index 00000000..3252b323 --- /dev/null +++ b/tests/outputs/test_ppt_link_refresh.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from datetime import date +from pathlib import Path + +from counter_risk.config import WorkflowConfig +from counter_risk.outputs import ( + OutputContext, + PptLinkRefreshOutputGenerator, + PptLinkRefreshResult, + PptLinkRefreshStatus, +) + + +def _minimal_config(tmp_path: Path) -> WorkflowConfig: + for filename in ( + "all.xlsx", + "ex.xlsx", + "trend.xlsx", + "hist_all.xlsx", + "hist_ex.xlsx", + "hist_llc.xlsx", + "monthly.pptx", + ): + (tmp_path / filename).write_bytes(b"placeholder") + + return WorkflowConfig( + mosers_all_programs_xlsx=tmp_path / "all.xlsx", + mosers_ex_trend_xlsx=tmp_path / "ex.xlsx", + mosers_trend_xlsx=tmp_path / "trend.xlsx", + hist_all_programs_3yr_xlsx=tmp_path / "hist_all.xlsx", + hist_ex_llc_3yr_xlsx=tmp_path / "hist_ex.xlsx", + hist_llc_3yr_xlsx=tmp_path / "hist_llc.xlsx", + monthly_pptx=tmp_path / "monthly.pptx", + ) + + +def _output_context(tmp_path: Path) -> OutputContext: + return OutputContext( + config=_minimal_config(tmp_path), + run_dir=tmp_path / "run", + as_of_date=date(2026, 1, 31), + run_date=date(2026, 2, 1), + ) + + +def test_ppt_link_refresh_generator_runs_refresher_and_records_success(tmp_path: Path) -> None: + context = _output_context(tmp_path) + warnings: list[str] = [] + seen: dict[str, Path] = {} + + def _refresh(pptx_path: Path) -> object: + seen["path"] = pptx_path + return PptLinkRefreshResult(status=PptLinkRefreshStatus.SUCCESS) + + generator = PptLinkRefreshOutputGenerator( + warnings=warnings, + ppt_link_refresher=_refresh, + ) + + generated = generator.generate(context=context) + + assert generated == () + assert seen["path"] == ( + context.run_dir / "Monthly Counterparty Exposure Report (Master) - 2026-01-31.pptx" + ) + assert generator.last_result == PptLinkRefreshResult(status=PptLinkRefreshStatus.SUCCESS) + assert warnings == [] + + +def test_ppt_link_refresh_generator_interprets_false_as_skipped(tmp_path: Path) -> None: + context = _output_context(tmp_path) + warnings: list[str] = [] + + generator = PptLinkRefreshOutputGenerator( + warnings=warnings, + ppt_link_refresher=lambda _pptx_path: False, + ) + + generator.generate(context=context) + + assert generator.last_result == PptLinkRefreshResult(status=PptLinkRefreshStatus.SKIPPED) + assert warnings == ["PPT links not refreshed; COM refresh skipped"] + + +def test_ppt_link_refresh_generator_captures_refresh_exceptions(tmp_path: Path) -> None: + context = _output_context(tmp_path) + warnings: list[str] = [] + + def _refresh(_pptx_path: Path) -> object: + raise RuntimeError("forced refresh failure") + + generator = PptLinkRefreshOutputGenerator( + warnings=warnings, + ppt_link_refresher=_refresh, + ) + + generator.generate(context=context) + + assert generator.last_result == PptLinkRefreshResult( + status=PptLinkRefreshStatus.FAILED, + error_detail="forced refresh failure", + ) + assert warnings == ["PPT links refresh failed; forced refresh failure"] From b1b4574c8814ec8df220c5c0bbfbbfeacc00a628 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 26 Feb 2026 03:18:55 +0000 Subject: [PATCH 13/18] fix: resolve CI failures --- src/counter_risk/outputs/ppt_link_refresh.py | 5 ++++- src/counter_risk/outputs/ppt_screenshot.py | 6 ++++- src/counter_risk/pipeline/run.py | 23 +++++++++++++++----- tests/outputs/test_ppt_screenshot.py | 2 +- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/counter_risk/outputs/ppt_link_refresh.py b/src/counter_risk/outputs/ppt_link_refresh.py index 08d64eeb..3b02298a 100644 --- a/src/counter_risk/outputs/ppt_link_refresh.py +++ b/src/counter_risk/outputs/ppt_link_refresh.py @@ -65,7 +65,10 @@ def generate(self, *, context: OutputContext) -> tuple[Path, ...]: error_detail=str(exc), ) else: - result = self.refresh_result_resolver(raw_result) + resolver = self.refresh_result_resolver + if resolver is None: + raise RuntimeError("PPT link refresh result resolver was not initialized") + result = resolver(raw_result) object.__setattr__(self, "last_result", result) diff --git a/src/counter_risk/outputs/ppt_screenshot.py b/src/counter_risk/outputs/ppt_screenshot.py index f77bc60b..167c0809 100644 --- a/src/counter_risk/outputs/ppt_screenshot.py +++ b/src/counter_risk/outputs/ppt_screenshot.py @@ -20,6 +20,10 @@ _PptCopier = Callable[[str | Path, str | Path], str] +def _copy_ppt(source: str | Path, destination: str | Path) -> str: + return shutil.copy2(str(source), str(destination)) + + @dataclass(frozen=True) class PptScreenshotOutputGenerator(OutputGenerator): """Generate the Master PPT output with optional screenshot replacement.""" @@ -30,7 +34,7 @@ class PptScreenshotOutputGenerator(OutputGenerator): master_link_target_validator: _MasterLinkTargetValidator name: str = "ppt_screenshot" ppt_output_names_resolver: _PptOutputNamesResolver = resolve_ppt_output_names - ppt_copier: _PptCopier = shutil.copy2 + ppt_copier: _PptCopier = _copy_ppt def generate(self, *, context: OutputContext) -> tuple[Path, ...]: config = context.config diff --git a/src/counter_risk/pipeline/run.py b/src/counter_risk/pipeline/run.py index 40ee715b..fc70ce57 100644 --- a/src/counter_risk/pipeline/run.py +++ b/src/counter_risk/pipeline/run.py @@ -12,7 +12,7 @@ from datetime import date from enum import StrEnum from pathlib import Path -from typing import Any, Literal, NoReturn +from typing import TYPE_CHECKING, Any, Literal, NoReturn from zipfile import BadZipFile from counter_risk.config import WorkflowConfig, load_config @@ -30,7 +30,7 @@ ParsedDataMissingKeyError, UnmappedCounterpartyError, ) -from counter_risk.pipeline.ppt_naming import resolve_ppt_output_names +from counter_risk.pipeline.ppt_naming import PptOutputNames, resolve_ppt_output_names from counter_risk.pipeline.ppt_validation import validate_distribution_ppt_standalone from counter_risk.pipeline.run_folder_outputs import ( RunFolderReadmePptOutputs, @@ -46,6 +46,9 @@ LOGGER = logging.getLogger(__name__) +if TYPE_CHECKING: + from counter_risk.outputs.ppt_link_refresh import PptLinkRefreshOutputGenerator + _EXPECTED_VARIANTS: tuple[str, ...] = ("all_programs", "ex_trend", "trend") # PowerPoint COM shape type constants used when identifying OLE/chart shapes. @@ -927,7 +930,10 @@ def _write_outputs( run_date=config.run_date or as_of_date, warnings=tuple(warnings), ) - screenshot_generator = _build_ppt_screenshot_output_generator(warnings=warnings) + screenshot_generator = _build_ppt_screenshot_output_generator( + warnings=warnings, + ppt_output_names_resolver=resolve_ppt_output_names, + ) master_output_paths = list(screenshot_generator.generate(context=output_context)) if len(master_output_paths) != 1: raise RuntimeError( @@ -1669,7 +1675,11 @@ def _build_historical_workbook_output_generator( ) -def _build_ppt_screenshot_output_generator(*, warnings: list[str]) -> OutputGenerator: +def _build_ppt_screenshot_output_generator( + *, + warnings: list[str], + ppt_output_names_resolver: Callable[[date], PptOutputNames], +) -> OutputGenerator: from counter_risk.outputs.ppt_screenshot import PptScreenshotOutputGenerator return PptScreenshotOutputGenerator( @@ -1677,10 +1687,13 @@ def _build_ppt_screenshot_output_generator(*, warnings: list[str]) -> OutputGene screenshot_input_mapping_resolver=_resolve_screenshot_input_mapping, screenshot_replacer_resolver=_get_screenshot_replacer, master_link_target_validator=_assert_master_preserves_external_link_targets, + ppt_output_names_resolver=ppt_output_names_resolver, ) -def _build_ppt_link_refresh_output_generator(*, warnings: list[str]): +def _build_ppt_link_refresh_output_generator( + *, warnings: list[str] +) -> PptLinkRefreshOutputGenerator: from counter_risk.outputs.ppt_link_refresh import PptLinkRefreshOutputGenerator return PptLinkRefreshOutputGenerator( diff --git a/tests/outputs/test_ppt_screenshot.py b/tests/outputs/test_ppt_screenshot.py index 11243ae4..bf782daf 100644 --- a/tests/outputs/test_ppt_screenshot.py +++ b/tests/outputs/test_ppt_screenshot.py @@ -1,8 +1,8 @@ from __future__ import annotations +from collections.abc import Callable from datetime import date from pathlib import Path -from typing import Callable from counter_risk.config import WorkflowConfig from counter_risk.outputs import OutputContext, PptScreenshotOutputGenerator From 780914d4bc7d26e7c39778b544cd2625b68f3d57 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 03:36:00 +0000 Subject: [PATCH 14/18] chore(codex-keepalive): apply updates (PR #256) --- src/counter_risk/outputs/__init__.py | 2 + src/counter_risk/outputs/pdf_export.py | 77 +++++++++++++++++ src/counter_risk/pipeline/run.py | 73 ++++++++-------- tests/outputs/test_pdf_export.py | 112 +++++++++++++++++++++++++ tests/test_pdf_export.py | 84 ++++++++----------- 5 files changed, 260 insertions(+), 88 deletions(-) create mode 100644 src/counter_risk/outputs/pdf_export.py create mode 100644 tests/outputs/test_pdf_export.py diff --git a/src/counter_risk/outputs/__init__.py b/src/counter_risk/outputs/__init__.py index dacbfd95..2d93ffe4 100644 --- a/src/counter_risk/outputs/__init__.py +++ b/src/counter_risk/outputs/__init__.py @@ -5,6 +5,7 @@ HistoricalWalWorkbookOutputGenerator, HistoricalWorkbookOutputGenerator, ) +from .pdf_export import PDFExportGenerator from .ppt_link_refresh import ( PptLinkRefreshOutputGenerator, PptLinkRefreshResult, @@ -17,6 +18,7 @@ "HistoricalWorkbookOutputGenerator", "OutputContext", "OutputGenerator", + "PDFExportGenerator", "PptLinkRefreshOutputGenerator", "PptLinkRefreshResult", "PptLinkRefreshStatus", diff --git a/src/counter_risk/outputs/pdf_export.py b/src/counter_risk/outputs/pdf_export.py new file mode 100644 index 00000000..8b558aaf --- /dev/null +++ b/src/counter_risk/outputs/pdf_export.py @@ -0,0 +1,77 @@ +"""Output generator for distribution PDF export via PowerPoint COM.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from counter_risk.integrations.powerpoint_com import is_powerpoint_com_available +from counter_risk.outputs.base import OutputContext, OutputGenerator + +LOGGER = logging.getLogger(__name__) +_PDF_FIXED_FORMAT_TYPE = 2 # ppFixedFormatTypePDF + +_ComAvailabilityChecker = Callable[[], bool] +_PptxToPdfExporter = Callable[[Path, Path], None] + + +def export_pptx_to_pdf_via_com(*, source_pptx: Path, pdf_path: Path) -> None: + """Export a PowerPoint deck to PDF using PowerPoint COM automation.""" + from counter_risk.integrations.powerpoint_com import initialize_powerpoint_application + + app: Any | None = None + presentation: Any | None = None + try: + app = initialize_powerpoint_application() + with suppress(Exception): + app.Visible = 0 + presentation = app.Presentations.Open(str(source_pptx), False, True, False) + presentation.ExportAsFixedFormat(str(pdf_path), _PDF_FIXED_FORMAT_TYPE) + finally: + if presentation is not None: + with suppress(Exception): + presentation.Close() + if app is not None: + with suppress(Exception): + app.Quit() + + +@dataclass(frozen=True) +class PDFExportGenerator(OutputGenerator): + """Generate a distribution PDF output when COM export is available.""" + + source_pptx: Path + warnings: list[str] + name: str = "pdf_export" + com_availability_checker: _ComAvailabilityChecker = is_powerpoint_com_available + pptx_to_pdf_exporter: _PptxToPdfExporter = export_pptx_to_pdf_via_com + + def generate(self, *, context: OutputContext) -> tuple[Path, ...]: + if not context.config.export_pdf: + LOGGER.warning("distribution_pdf_skipped reason=export_pdf_disabled") + return () + + if not self.com_availability_checker(): + warning = ( + "distribution_pdf requested but PowerPoint COM is unavailable; " + "skipping PDF generation" + ) + self.warnings.append(warning) + LOGGER.warning( + "distribution_pdf_skipped reason=com_unavailable source=%s", self.source_pptx + ) + return () + + pdf_path = context.run_dir / f"{self.source_pptx.stem}.pdf" + try: + self.pptx_to_pdf_exporter(self.source_pptx, pdf_path) + except Exception as exc: + LOGGER.error("PDF export failed: %s", exc) + raise RuntimeError(f"PDF export failed: {exc}") from exc + + LOGGER.info("distribution_pdf_complete path=%s", pdf_path) + return (pdf_path,) diff --git a/src/counter_risk/pipeline/run.py b/src/counter_risk/pipeline/run.py index fc70ce57..c4cc3440 100644 --- a/src/counter_risk/pipeline/run.py +++ b/src/counter_risk/pipeline/run.py @@ -993,6 +993,8 @@ def _write_outputs( source_pptx=target_distribution_ppt, run_dir=run_dir, config=config, + as_of_date=as_of_date, + warnings=warnings, ) if distribution_pdf_path is not None: output_paths.append(distribution_pdf_path) @@ -1360,51 +1362,34 @@ def _export_distribution_pdf( source_pptx: Path, run_dir: Path, config: WorkflowConfig, + as_of_date: date, + warnings: list[str], ) -> Path | None: - if not config.export_pdf: - LOGGER.warning("distribution_pdf_skipped reason=export_pdf_disabled") + output_context = OutputContext( + config=config, + run_dir=run_dir, + as_of_date=as_of_date, + run_date=config.run_date or as_of_date, + warnings=tuple(warnings), + ) + pdf_output_generator = _build_pdf_export_output_generator( + source_pptx=source_pptx, + warnings=warnings, + ) + generated_paths = pdf_output_generator.generate(context=output_context) + if not generated_paths: return None - pdf_path = run_dir / f"{source_pptx.stem}.pdf" - _export_pptx_to_pdf(source_pptx=source_pptx, pdf_path=pdf_path) - return pdf_path + if len(generated_paths) != 1: + raise RuntimeError("PDF export output generator must produce at most one PDF output") + + return generated_paths[0] def _export_pptx_to_pdf(*, source_pptx: Path, pdf_path: Path) -> None: - if platform.system().lower() != "windows": - error = RuntimeError("unsupported platform for COM PDF export") - LOGGER.error("PDF export failed: %s", error) - raise error + from counter_risk.outputs.pdf_export import export_pptx_to_pdf_via_com - try: - import win32com.client - except ImportError as exc: - error = RuntimeError("win32com is not installed") - LOGGER.error("PDF export failed: %s", error) - raise error from exc - - app = None - presentation = None - try: - app = win32com.client.DispatchEx("PowerPoint.Application") - app.Visible = False - presentation = app.Presentations.Open(str(source_pptx), WithWindow=False) - presentation.ExportAsFixedFormat(str(pdf_path), 2) # 2 = ppFixedFormatTypePDF - LOGGER.info("distribution_pdf_complete path=%s", pdf_path) - except Exception as exc: - LOGGER.error("PDF export failed: %s", exc) - raise RuntimeError(f"PDF export failed: {exc}") from exc - finally: - if presentation is not None: - try: - presentation.Close() - except Exception as exc: - LOGGER.warning("distribution_pdf_cleanup_failed action=close exc=%s", exc) - if app is not None: - try: - app.Quit() - except Exception as exc: - LOGGER.warning("distribution_pdf_cleanup_failed action=quit exc=%s", exc) + export_pptx_to_pdf_via_com(source_pptx=source_pptx, pdf_path=pdf_path) def _export_slides_as_images( @@ -1702,6 +1687,18 @@ def _build_ppt_link_refresh_output_generator( ) +def _build_pdf_export_output_generator( + *, source_pptx: Path, warnings: list[str] +) -> OutputGenerator: + from counter_risk.outputs.pdf_export import PDFExportGenerator + + return PDFExportGenerator( + source_pptx=source_pptx, + warnings=warnings, + pptx_to_pdf_exporter=lambda src, dst: _export_pptx_to_pdf(source_pptx=src, pdf_path=dst), + ) + + def _to_ppt_processing_result(refresh_result: object | None) -> PptProcessingResult: if refresh_result is None: raise RuntimeError("PPT link refresh output generator did not record a refresh result") diff --git a/tests/outputs/test_pdf_export.py b/tests/outputs/test_pdf_export.py new file mode 100644 index 00000000..838461c2 --- /dev/null +++ b/tests/outputs/test_pdf_export.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from datetime import date +from pathlib import Path + +import pytest + +from counter_risk.config import WorkflowConfig +from counter_risk.outputs import OutputContext +from counter_risk.outputs.pdf_export import PDFExportGenerator + + +def _minimal_config(tmp_path: Path, *, export_pdf: bool) -> WorkflowConfig: + for filename in ( + "all.xlsx", + "ex.xlsx", + "trend.xlsx", + "hist_all.xlsx", + "hist_ex.xlsx", + "hist_llc.xlsx", + "monthly.pptx", + ): + (tmp_path / filename).write_bytes(b"placeholder") + + return WorkflowConfig( + mosers_all_programs_xlsx=tmp_path / "all.xlsx", + mosers_ex_trend_xlsx=tmp_path / "ex.xlsx", + mosers_trend_xlsx=tmp_path / "trend.xlsx", + hist_all_programs_3yr_xlsx=tmp_path / "hist_all.xlsx", + hist_ex_llc_3yr_xlsx=tmp_path / "hist_ex.xlsx", + hist_llc_3yr_xlsx=tmp_path / "hist_llc.xlsx", + monthly_pptx=tmp_path / "monthly.pptx", + export_pdf=export_pdf, + ) + + +def _output_context(tmp_path: Path, *, export_pdf: bool) -> OutputContext: + return OutputContext( + config=_minimal_config(tmp_path, export_pdf=export_pdf), + run_dir=tmp_path / "run", + as_of_date=date(2026, 1, 31), + run_date=date(2026, 2, 1), + ) + + +def test_pdf_export_generator_exports_when_com_is_available(tmp_path: Path) -> None: + context = _output_context(tmp_path, export_pdf=True) + source_pptx = context.run_dir / "distribution.pptx" + source_pptx.parent.mkdir(parents=True, exist_ok=True) + source_pptx.write_bytes(b"pptx") + warnings: list[str] = [] + calls: list[tuple[Path, Path]] = [] + + def _exporter(source: Path, output: Path) -> None: + calls.append((source, output)) + output.write_bytes(b"pdf") + + generator = PDFExportGenerator( + source_pptx=source_pptx, + warnings=warnings, + com_availability_checker=lambda: True, + pptx_to_pdf_exporter=_exporter, + ) + + generated = generator.generate(context=context) + + expected_pdf = context.run_dir / "distribution.pdf" + assert generated == (expected_pdf,) + assert calls == [(source_pptx, expected_pdf)] + assert warnings == [] + + +def test_pdf_export_generator_warns_and_skips_when_com_unavailable(tmp_path: Path) -> None: + context = _output_context(tmp_path, export_pdf=True) + source_pptx = context.run_dir / "distribution.pptx" + warnings: list[str] = [] + + def _unexpected_export(_source: Path, _output: Path) -> None: + raise AssertionError("exporter should not run when COM is unavailable") + + generator = PDFExportGenerator( + source_pptx=source_pptx, + warnings=warnings, + com_availability_checker=lambda: False, + pptx_to_pdf_exporter=_unexpected_export, + ) + + generated = generator.generate(context=context) + + assert generated == () + assert warnings == [ + "distribution_pdf requested but PowerPoint COM is unavailable; skipping PDF generation" + ] + + +def test_pdf_export_generator_raises_when_export_fails(tmp_path: Path) -> None: + context = _output_context(tmp_path, export_pdf=True) + source_pptx = context.run_dir / "distribution.pptx" + warnings: list[str] = [] + + def _failing_export(_source: Path, _output: Path) -> None: + raise RuntimeError("boom export") + + generator = PDFExportGenerator( + source_pptx=source_pptx, + warnings=warnings, + com_availability_checker=lambda: True, + pptx_to_pdf_exporter=_failing_export, + ) + + with pytest.raises(RuntimeError, match="PDF export failed: boom export"): + generator.generate(context=context) diff --git a/tests/test_pdf_export.py b/tests/test_pdf_export.py index e4e9fc40..e2f82a1e 100644 --- a/tests/test_pdf_export.py +++ b/tests/test_pdf_export.py @@ -1,11 +1,6 @@ from __future__ import annotations -import sys -import types from pathlib import Path -from typing import Any, cast - -import pytest from counter_risk.config import WorkflowConfig from counter_risk.pipeline import run as run_module @@ -14,7 +9,6 @@ def _make_minimal_config( tmp_path: Path, *, - distribution_static: bool = False, export_pdf: bool = False, ) -> WorkflowConfig: tmp_path.mkdir(parents=True, exist_ok=True) @@ -36,78 +30,68 @@ def _make_minimal_config( hist_ex_llc_3yr_xlsx=tmp_path / "hist_ex.xlsx", hist_llc_3yr_xlsx=tmp_path / "hist_llc.xlsx", monthly_pptx=tmp_path / "monthly.pptx", - distribution_static=distribution_static, export_pdf=export_pdf, output_root=tmp_path / "runs", ) -def test_export_distribution_pdf_raises_and_logs_when_export_fails( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture -) -> None: - monkeypatch.setattr("counter_risk.pipeline.run.platform.system", lambda: "Windows") +class _FakeGenerator: + name = "fake-pdf" + + def __init__(self, generated: tuple[Path, ...]) -> None: + self._generated = generated + + def generate(self, *, context: object) -> tuple[Path, ...]: + _ = context + return self._generated + +def test_export_distribution_pdf_returns_generated_path(tmp_path: Path, monkeypatch) -> None: source_pptx = tmp_path / "distribution.pptx" source_pptx.write_bytes(b"fake-pptx") run_dir = tmp_path / "run" run_dir.mkdir() config = _make_minimal_config(tmp_path / "cfg", export_pdf=True) + pdf_path = run_dir / "distribution.pdf" - class _FakePresentation: - def ExportAsFixedFormat(self, _path: str, _fmt: int) -> None: # noqa: N802 - raise RuntimeError("boom export") - - def Close(self) -> None: # noqa: N802 - return None - - class _FakePowerPointApplication: - def __init__(self) -> None: - self.Visible = False - self.Presentations = types.SimpleNamespace( - Open=lambda *_args, **_kwargs: _FakePresentation() - ) - - def Quit(self) -> None: # noqa: N802 - return None - - fake_client = types.SimpleNamespace( - DispatchEx=lambda *_args, **_kwargs: _FakePowerPointApplication() + monkeypatch.setattr( + run_module, + "_build_pdf_export_output_generator", + lambda **_kwargs: _FakeGenerator((pdf_path,)), ) - fake_win32com = types.ModuleType("win32com") - cast(Any, fake_win32com).client = fake_client - monkeypatch.setitem(sys.modules, "win32com", fake_win32com) - monkeypatch.setitem(sys.modules, "win32com.client", fake_client) - with pytest.raises(RuntimeError, match="PDF export failed: boom export"): - run_module._export_distribution_pdf( - source_pptx=source_pptx, - run_dir=run_dir, - config=config, - ) + result = run_module._export_distribution_pdf( + source_pptx=source_pptx, + run_dir=run_dir, + config=config, + as_of_date=config.as_of_date or run_module.date(2026, 2, 26), + warnings=[], + ) - assert "PDF export failed" in caplog.text - assert "boom export" in caplog.text + assert result == pdf_path -def test_export_distribution_pdf_skips_when_disabled( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +def test_export_distribution_pdf_returns_none_when_generator_skips( + tmp_path: Path, monkeypatch ) -> None: source_pptx = tmp_path / "distribution.pptx" source_pptx.write_bytes(b"fake-pptx") run_dir = tmp_path / "run" run_dir.mkdir() - config = _make_minimal_config(tmp_path / "cfg", export_pdf=False) - - def _fail_if_called(*, source_pptx: Path, pdf_path: Path) -> None: - raise AssertionError(f"exporter should not be called for {source_pptx} -> {pdf_path}") + config = _make_minimal_config(tmp_path / "cfg", export_pdf=True) - monkeypatch.setattr(run_module, "_export_pptx_to_pdf", _fail_if_called) + monkeypatch.setattr( + run_module, + "_build_pdf_export_output_generator", + lambda **_kwargs: _FakeGenerator(()), + ) result = run_module._export_distribution_pdf( source_pptx=source_pptx, run_dir=run_dir, config=config, + as_of_date=run_module.date(2026, 2, 26), + warnings=[], ) assert result is None - assert "distribution_pdf_skipped reason=export_pdf_disabled" in caplog.text From 8af2f2d1f15f1a9aa9bd0e2529cb1bbac7423f59 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Feb 2026 03:53:24 +0000 Subject: [PATCH 15/18] fix: remove "task" from non-issue prefix filter in extractIssueNumberFromPull "Task #123" is a valid issue reference pattern. Keeping "task" in the filter caused extractIssueNumberFromPull to return null, breaking downstream PR metadata updates. https://claude.ai/code/session_01JhCWWDJG8PqwaSbVPCGfm6 --- .github/scripts/agents_pr_meta_keepalive.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/agents_pr_meta_keepalive.js b/.github/scripts/agents_pr_meta_keepalive.js index 10eab2c8..32cfa95c 100644 --- a/.github/scripts/agents_pr_meta_keepalive.js +++ b/.github/scripts/agents_pr_meta_keepalive.js @@ -242,7 +242,7 @@ function extractIssueNumberFromPull(pull) { } // Skip non-issue refs like "Run #123", "run #123", "attempt #2" const preceding = bodyText.slice(Math.max(0, match.index - 20), match.index); - if (/\b(?:run|attempt|step|job|check|task|version|v)\s*$/i.test(preceding)) { + if (/\b(?:run|attempt|step|job|check|version|v)\s*$/i.test(preceding)) { continue; } candidates.push(match[1]); From 6b93a838ab39a820fccf49ebc4c5e67c2fd38712 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 04:15:42 +0000 Subject: [PATCH 16/18] chore(codex-keepalive): apply updates (PR #256) --- src/counter_risk/outputs/pdf_export.py | 2 +- tests/test_historical_workbook_validation.py | 7 ++++--- tests/test_normalization_registry_first.py | 11 +++++++---- tests/test_pdf_export.py | 15 ++++++++++----- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/counter_risk/outputs/pdf_export.py b/src/counter_risk/outputs/pdf_export.py index 8b558aaf..fd7706e2 100644 --- a/src/counter_risk/outputs/pdf_export.py +++ b/src/counter_risk/outputs/pdf_export.py @@ -19,7 +19,7 @@ _PptxToPdfExporter = Callable[[Path, Path], None] -def export_pptx_to_pdf_via_com(*, source_pptx: Path, pdf_path: Path) -> None: +def export_pptx_to_pdf_via_com(source_pptx: Path, pdf_path: Path) -> None: """Export a PowerPoint deck to PDF using PowerPoint COM automation.""" from counter_risk.integrations.powerpoint_com import initialize_powerpoint_application diff --git a/tests/test_historical_workbook_validation.py b/tests/test_historical_workbook_validation.py index 0ef7fa76..8d7c3523 100644 --- a/tests/test_historical_workbook_validation.py +++ b/tests/test_historical_workbook_validation.py @@ -10,8 +10,9 @@ import pytest +import counter_risk.pipeline.run as run_module from counter_risk.config import WorkflowConfig -from counter_risk.pipeline import run as run_module +from counter_risk.outputs.base import OutputContext class _FakeCell: @@ -436,7 +437,7 @@ def test_historical_output_generator_builder_preserves_variant_merge_contract( ) warnings: list[str] = [] - parsed_by_variant = { + parsed_by_variant: dict[str, dict[str, Any]] = { "all_programs": {"totals": [{"counterparty": "A", "Notional": 10.0}, "skip-me"]}, "ex_trend": {"totals": [{"counterparty": "B", "Notional": 4.0}]}, "trend": {"totals": [{"counterparty": "C", "Notional": 7.0}]}, @@ -462,7 +463,7 @@ def _fake_merge( ) output_paths = generator.generate( - context=run_module.OutputContext( + context=OutputContext( config=config, run_dir=run_dir, as_of_date=date(2026, 2, 13), diff --git a/tests/test_normalization_registry_first.py b/tests/test_normalization_registry_first.py index e6e2be2e..913e02de 100644 --- a/tests/test_normalization_registry_first.py +++ b/tests/test_normalization_registry_first.py @@ -5,10 +5,10 @@ import logging import shutil from pathlib import Path +from typing import Any import pytest -import counter_risk.pipeline.run as pipeline_run from counter_risk.normalize import ( normalize_counterparty_with_source, resolve_clearing_house, @@ -225,14 +225,17 @@ def _run_with_fixture(fixture_name: str, run_dir: Path) -> list[str]: monkeypatch.chdir(run_dir) captured_sources: list[str] = [] - original = pipeline_run.normalize_counterparty_with_source + original = normalize_counterparty_with_source - def _capture_source(raw_name: str, **kwargs): + def _capture_source(raw_name: str, **kwargs: Any) -> Any: resolution = original(raw_name, **kwargs) captured_sources.append(resolution.source) return resolution - monkeypatch.setattr(pipeline_run, "normalize_counterparty_with_source", _capture_source) + monkeypatch.setattr( + "counter_risk.pipeline.run.normalize_counterparty_with_source", + _capture_source, + ) reconcile_series_coverage( parsed_data_by_sheet={ "Total": { diff --git a/tests/test_pdf_export.py b/tests/test_pdf_export.py index e2f82a1e..ea8c2d5f 100644 --- a/tests/test_pdf_export.py +++ b/tests/test_pdf_export.py @@ -1,9 +1,12 @@ from __future__ import annotations +from datetime import date from pathlib import Path +import pytest + +import counter_risk.pipeline.run as run_module from counter_risk.config import WorkflowConfig -from counter_risk.pipeline import run as run_module def _make_minimal_config( @@ -46,7 +49,9 @@ def generate(self, *, context: object) -> tuple[Path, ...]: return self._generated -def test_export_distribution_pdf_returns_generated_path(tmp_path: Path, monkeypatch) -> None: +def test_export_distribution_pdf_returns_generated_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: source_pptx = tmp_path / "distribution.pptx" source_pptx.write_bytes(b"fake-pptx") run_dir = tmp_path / "run" @@ -64,7 +69,7 @@ def test_export_distribution_pdf_returns_generated_path(tmp_path: Path, monkeypa source_pptx=source_pptx, run_dir=run_dir, config=config, - as_of_date=config.as_of_date or run_module.date(2026, 2, 26), + as_of_date=config.as_of_date or date(2026, 2, 26), warnings=[], ) @@ -72,7 +77,7 @@ def test_export_distribution_pdf_returns_generated_path(tmp_path: Path, monkeypa def test_export_distribution_pdf_returns_none_when_generator_skips( - tmp_path: Path, monkeypatch + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: source_pptx = tmp_path / "distribution.pptx" source_pptx.write_bytes(b"fake-pptx") @@ -90,7 +95,7 @@ def test_export_distribution_pdf_returns_none_when_generator_skips( source_pptx=source_pptx, run_dir=run_dir, config=config, - as_of_date=run_module.date(2026, 2, 26), + as_of_date=date(2026, 2, 26), warnings=[], ) From 433e11c0a993365df26f29c6345c1d0c1a078b14 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 26 Feb 2026 04:38:00 +0000 Subject: [PATCH 17/18] Add config-driven output generator registry and stage filtering --- README.md | 33 ++++++++ src/counter_risk/config.py | 60 +++++++++++++ src/counter_risk/outputs/__init__.py | 3 + src/counter_risk/outputs/registry.py | 102 ++++++++++++++++++++++ src/counter_risk/pipeline/run.py | 122 +++++++++++++++++++++------ tests/test_config.py | 70 +++++++++++++++ tests/test_pipeline_run_outputs.py | 84 +++++++++++++++++- 7 files changed, 448 insertions(+), 26 deletions(-) create mode 100644 src/counter_risk/outputs/registry.py diff --git a/README.md b/README.md index e60a7b63..cf6f5bdd 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,39 @@ A programmatic replacement for the MOSERS spreadsheet workflow used to evaluate - A run folder with a manifest (inputs, hashes, warnings, summaries) - Optional “distribution” deliverables (static PPT/PDF) that do not prompt for link refresh +## Output Generator Configuration + +Outputs are registered per run via `output_generators` in YAML config files. Each entry has: + +- `name`: unique identifier for the generator +- `registration`: either `builtin:` or `:` +- `stage`: one of `historical`, `ppt_master`, `ppt_refresh`, `ppt_post_distribution` +- `enabled`: `true`/`false` + +Example: + +```yaml +output_generators: + - name: historical_workbook + registration: builtin:historical_workbook + stage: historical + enabled: true + - name: ppt_screenshot + registration: builtin:ppt_screenshot + stage: ppt_master + enabled: true + - name: ppt_link_refresh + registration: builtin:ppt_link_refresh + stage: ppt_refresh + enabled: false + - name: custom_exhibit + registration: my_package.outputs:CustomExhibitGenerator + stage: ppt_post_distribution + enabled: true +``` + +Setting `enabled: false` cleanly skips that generator for the run. + ## Start here - **Project agent guide:** [AGENTS.md](AGENTS.md) diff --git a/src/counter_risk/config.py b/src/counter_risk/config.py index 7c619393..1cc9641b 100644 --- a/src/counter_risk/config.py +++ b/src/counter_risk/config.py @@ -28,6 +28,50 @@ class InputDiscoveryConfig(BaseModel): naming_patterns: dict[str, list[str]] = Field(default_factory=dict) +class OutputGeneratorConfig(BaseModel): + """Registration record for one output generator.""" + + model_config = ConfigDict(extra="forbid") + + name: str + registration: str + stage: Literal["historical", "ppt_master", "ppt_refresh", "ppt_post_distribution"] + enabled: bool = True + + @field_validator("name", "registration") + @classmethod + def _validate_non_blank(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("Value must be non-empty") + return normalized + + +def _default_output_generators() -> tuple[OutputGeneratorConfig, ...]: + return ( + OutputGeneratorConfig( + name="historical_workbook", + registration="builtin:historical_workbook", + stage="historical", + ), + OutputGeneratorConfig( + name="ppt_screenshot", + registration="builtin:ppt_screenshot", + stage="ppt_master", + ), + OutputGeneratorConfig( + name="ppt_link_refresh", + registration="builtin:ppt_link_refresh", + stage="ppt_refresh", + ), + OutputGeneratorConfig( + name="pdf_export", + registration="builtin:pdf_export", + stage="ppt_post_distribution", + ), + ) + + class WorkflowConfig(BaseModel): """Configuration for a single Counter Risk workflow execution. @@ -56,6 +100,9 @@ class WorkflowConfig(BaseModel): distribution_static: bool = False export_pdf: bool = False enable_ppt_output: bool = True + output_generators: tuple[OutputGeneratorConfig, ...] = Field( + default_factory=_default_output_generators + ) output_root: Path = Path("runs") @property @@ -83,6 +130,19 @@ def _validate_optional_iso_date(cls, value: Any) -> Any: raise ValueError("Value must be a valid ISO date (YYYY-MM-DD)") from exc raise ValueError("Value must be a valid ISO date (YYYY-MM-DD)") + @field_validator("output_generators") + @classmethod + def _validate_unique_output_generator_names( + cls, value: tuple[OutputGeneratorConfig, ...] + ) -> tuple[OutputGeneratorConfig, ...]: + seen: set[str] = set() + for entry in value: + normalized_name = entry.name.casefold() + if normalized_name in seen: + raise ValueError(f"output_generators contains duplicate name: {entry.name!r}") + seen.add(normalized_name) + return value + def _format_validation_error(error: ValidationError) -> str: lines = ["Configuration validation failed:"] diff --git a/src/counter_risk/outputs/__init__.py b/src/counter_risk/outputs/__init__.py index 2d93ffe4..0bd6e110 100644 --- a/src/counter_risk/outputs/__init__.py +++ b/src/counter_risk/outputs/__init__.py @@ -12,12 +12,15 @@ PptLinkRefreshStatus, ) from .ppt_screenshot import PptScreenshotOutputGenerator +from .registry import OutputGeneratorRegistry, OutputGeneratorRegistryContext __all__ = [ "HistoricalWalWorkbookOutputGenerator", "HistoricalWorkbookOutputGenerator", "OutputContext", "OutputGenerator", + "OutputGeneratorRegistry", + "OutputGeneratorRegistryContext", "PDFExportGenerator", "PptLinkRefreshOutputGenerator", "PptLinkRefreshResult", diff --git a/src/counter_risk/outputs/registry.py b/src/counter_risk/outputs/registry.py new file mode 100644 index 00000000..677cb2eb --- /dev/null +++ b/src/counter_risk/outputs/registry.py @@ -0,0 +1,102 @@ +"""Config-driven output generator registry.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass +from importlib import import_module +from pathlib import Path +from typing import Any + +from counter_risk.config import OutputGeneratorConfig +from counter_risk.outputs.base import OutputGenerator + +_OUTPUT_REGISTRATION_PREFIX = "builtin:" + + +@dataclass(frozen=True) +class OutputGeneratorRegistryContext: + """Dependencies used while constructing configured output generators.""" + + warnings: list[str] + parsed_by_variant: Mapping[str, Mapping[str, Any]] | None = None + source_pptx: Path | None = None + + +_OutputGeneratorFactory = Callable[[OutputGeneratorRegistryContext], OutputGenerator] + + +class OutputGeneratorRegistry: + """Load and instantiate output generators declared in workflow config.""" + + def __init__(self, *, builtin_factories: Mapping[str, _OutputGeneratorFactory]) -> None: + self._builtin_factories = dict(builtin_factories) + + def load( + self, + *, + output_generators: Iterable[OutputGeneratorConfig], + stage: str, + context: OutputGeneratorRegistryContext, + ) -> tuple[OutputGenerator, ...]: + loaded: list[OutputGenerator] = [] + for registration in output_generators: + if not registration.enabled or registration.stage != stage: + continue + loaded.append(self._create(registration=registration, context=context)) + return tuple(loaded) + + def _create( + self, *, registration: OutputGeneratorConfig, context: OutputGeneratorRegistryContext + ) -> OutputGenerator: + generator = self._create_from_registration( + registration=registration.registration, + context=context, + ) + generated_name = str(getattr(generator, "name", "")).strip() + if generated_name and generated_name != registration.name: + raise ValueError( + "Configured output generator name does not match implementation name " + f"({registration.name!r} != {generated_name!r})" + ) + return generator + + def _create_from_registration( + self, *, registration: str, context: OutputGeneratorRegistryContext + ) -> OutputGenerator: + if registration.startswith(_OUTPUT_REGISTRATION_PREFIX): + builtin_name = registration.removeprefix(_OUTPUT_REGISTRATION_PREFIX) + factory = self._builtin_factories.get(builtin_name) + if factory is None: + raise ValueError(f"Unknown builtin output generator registration: {registration!r}") + return factory(context) + + module_name, separator, attribute_name = registration.partition(":") + if not separator or not module_name or not attribute_name: + raise ValueError( + "Output generator registration must be ':' or " "'builtin:'" + ) + module = import_module(module_name) + candidate = getattr(module, attribute_name) + + if hasattr(candidate, "generate") and not callable(candidate): + return _validate_output_generator(candidate) + + if callable(candidate): + try: + created = candidate(context) + except TypeError: + created = candidate() + return _validate_output_generator(created) + + raise TypeError( + f"Output generator registration {registration!r} resolved to unsupported object" + ) + + +def _validate_output_generator(candidate: object) -> OutputGenerator: + if not hasattr(candidate, "generate"): + raise TypeError( + "Configured output generator does not define a generate(context=...) method" + ) + return candidate # type: ignore[return-value] diff --git a/src/counter_risk/pipeline/run.py b/src/counter_risk/pipeline/run.py index c4cc3440..28dd9bb3 100644 --- a/src/counter_risk/pipeline/run.py +++ b/src/counter_risk/pipeline/run.py @@ -23,6 +23,7 @@ normalize_counterparty_with_source, ) from counter_risk.outputs.base import OutputContext, OutputGenerator +from counter_risk.outputs.registry import OutputGeneratorRegistry, OutputGeneratorRegistryContext from counter_risk.parsers import parse_fcm_totals, parse_futures_detail from counter_risk.pipeline.manifest import ManifestBuilder from counter_risk.pipeline.parsing_types import ( @@ -930,25 +931,48 @@ def _write_outputs( run_date=config.run_date or as_of_date, warnings=tuple(warnings), ) - screenshot_generator = _build_ppt_screenshot_output_generator( - warnings=warnings, - ppt_output_names_resolver=resolve_ppt_output_names, + registry = _build_output_generator_registry(warnings=warnings) + master_generators = registry.load( + output_generators=config.output_generators, + stage="ppt_master", + context=OutputGeneratorRegistryContext( + parsed_by_variant=None, + warnings=warnings, + ), ) - master_output_paths = list(screenshot_generator.generate(context=output_context)) - if len(master_output_paths) != 1: - raise RuntimeError( - "PPT screenshot output generator must produce exactly one Master PPT output" - ) - target_master_ppt = master_output_paths[0] - output_paths.extend(master_output_paths) + target_master_ppt: Path | None = None + for generator in master_generators: + generated_paths = tuple(generator.generate(context=output_context)) + output_paths.extend(generated_paths) + if generator.name == "ppt_screenshot": + if len(generated_paths) != 1: + raise RuntimeError( + "PPT screenshot output generator must produce exactly one Master PPT output" + ) + target_master_ppt = generated_paths[0] + + if target_master_ppt is None: + LOGGER.info("write_outputs_skip_ppt_no_master_generator run_dir=%s", run_dir) + return output_paths, PptProcessingResult(status=PptProcessingStatus.SKIPPED) + readme_ppt_outputs = RunFolderReadmePptOutputs( master=target_master_ppt.relative_to(run_dir), distribution=target_distribution_ppt.relative_to(run_dir), ) - link_refresh_generator = _build_ppt_link_refresh_output_generator(warnings=warnings) - link_refresh_generator.generate(context=output_context) - refresh_result = _to_ppt_processing_result(link_refresh_generator.last_result) + refresh_generators = registry.load( + output_generators=config.output_generators, + stage="ppt_refresh", + context=OutputGeneratorRegistryContext( + parsed_by_variant=None, + warnings=warnings, + ), + ) + refresh_result = PptProcessingResult(status=PptProcessingStatus.SKIPPED) + for generator in refresh_generators: + generator.generate(context=output_context) + if generator.name == "ppt_link_refresh": + refresh_result = _to_ppt_processing_result(getattr(generator, "last_result", None)) if refresh_result.status == PptProcessingStatus.FAILED: LOGGER.warning( @@ -989,15 +1013,17 @@ def _write_outputs( f"external relationships in: {rel_parts}" ) output_paths.append(target_distribution_ppt) - distribution_pdf_path = _export_distribution_pdf( - source_pptx=target_distribution_ppt, - run_dir=run_dir, - config=config, - as_of_date=as_of_date, - warnings=warnings, + post_distribution_generators = registry.load( + output_generators=config.output_generators, + stage="ppt_post_distribution", + context=OutputGeneratorRegistryContext( + parsed_by_variant=None, + source_pptx=target_distribution_ppt, + warnings=warnings, + ), ) - if distribution_pdf_path is not None: - output_paths.append(distribution_pdf_path) + for generator in post_distribution_generators: + output_paths.extend(generator.generate(context=output_context)) static_output_paths = _create_static_distribution( source_pptx=target_master_ppt, run_dir=run_dir, @@ -1628,9 +1654,14 @@ def _update_historical_outputs( warnings: list[str], ) -> list[Path]: LOGGER.info("historical_update_start run_dir=%s as_of_date=%s", run_dir, as_of_date.isoformat()) - output_generator = _build_historical_workbook_output_generator( - parsed_by_variant=parsed_by_variant, - warnings=warnings, + registry = _build_output_generator_registry(warnings=warnings) + output_generators = registry.load( + output_generators=config.output_generators, + stage="historical", + context=OutputGeneratorRegistryContext( + parsed_by_variant=parsed_by_variant, + warnings=warnings, + ), ) output_context = OutputContext( config=config, @@ -1639,12 +1670,53 @@ def _update_historical_outputs( run_date=config.run_date or as_of_date, warnings=tuple(warnings), ) - output_paths = list(output_generator.generate(context=output_context)) + output_paths: list[Path] = [] + for output_generator in output_generators: + output_paths.extend(output_generator.generate(context=output_context)) LOGGER.info("historical_update_complete workbook_count=%s", len(output_paths)) return output_paths +def _build_output_generator_registry( + *, + warnings: list[str], +) -> OutputGeneratorRegistry: + return OutputGeneratorRegistry( + builtin_factories={ + "historical_workbook": lambda registry_context: _build_historical_workbook_output_generator( + parsed_by_variant=_require_parsed_by_variant(registry_context.parsed_by_variant), + warnings=registry_context.warnings, + ), + "ppt_screenshot": lambda registry_context: _build_ppt_screenshot_output_generator( + warnings=registry_context.warnings, + ppt_output_names_resolver=resolve_ppt_output_names, + ), + "ppt_link_refresh": lambda registry_context: _build_ppt_link_refresh_output_generator( + warnings=registry_context.warnings + ), + "pdf_export": lambda registry_context: _build_pdf_export_output_generator( + source_pptx=_require_source_pptx(registry_context.source_pptx), + warnings=registry_context.warnings, + ), + } + ) + + +def _require_parsed_by_variant( + parsed_by_variant: Mapping[str, Mapping[str, Any]] | None, +) -> Mapping[str, Mapping[str, Any]]: + if parsed_by_variant is None: + raise ValueError("parsed_by_variant is required for historical output generation") + return parsed_by_variant + + +def _require_source_pptx(source_pptx: Path | None) -> Path: + if source_pptx is None: + raise ValueError("source_pptx is required for PDF export output generation") + return source_pptx + + def _build_historical_workbook_output_generator( *, parsed_by_variant: dict[str, dict[str, Any]], diff --git a/tests/test_config.py b/tests/test_config.py index 958ecfc8..2c43c2cd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -205,6 +205,76 @@ def test_workflow_config_defaults_screenshot_replacement_fields() -> None: assert config.reconciliation.fail_policy == "warn" assert config.reconciliation.expected_segments_by_variant == {} assert config.ppt_output_enabled is True + assert [entry.name for entry in config.output_generators] == [ + "historical_workbook", + "ppt_screenshot", + "ppt_link_refresh", + "pdf_export", + ] + + +def test_load_config_accepts_output_generator_registration(tmp_path: Path) -> None: + config_path = tmp_path / "output_generators.yml" + config_path.write_text( + "\n".join( + [ + "as_of_date: 2025-12-31", + "mosers_all_programs_xlsx: docs/N__A Data/MOSERS Counterparty Risk Summary 12-31-2025 - All Programs.xlsx", + "mosers_ex_trend_xlsx: docs/N__A Data/MOSERS Counterparty Risk Summary 12-31-2025 - Ex Trend.xlsx", + "mosers_trend_xlsx: docs/N__A Data/MOSERS Counterparty Risk Summary 12-31-2025 - Trend.xlsx", + "hist_all_programs_3yr_xlsx: docs/Ratings Instructiosns/Historical Counterparty Risk Graphs - All Programs 3 Year.xlsx", + "hist_ex_llc_3yr_xlsx: docs/Ratings Instructiosns/Historical Counterparty Risk Graphs - ex LLC 3 Year.xlsx", + "hist_llc_3yr_xlsx: docs/Ratings Instructiosns/Historical Counterparty Risk Graphs - LLC 3 Year.xlsx", + "monthly_pptx: docs/Ratings Instructiosns/Monthly Counterparty Exposure Report.pptx", + "output_root: runs/test", + "output_generators:", + " - name: ppt_screenshot", + " registration: builtin:ppt_screenshot", + " stage: ppt_master", + " enabled: true", + " - name: custom_manifest_stub", + " registration: tests.test_pipeline_run_outputs:_ConfigRegisteredOutputGenerator", + " stage: ppt_post_distribution", + " enabled: false", + ] + ) + + "\n", + encoding="utf-8", + ) + + config = load_config(config_path) + assert len(config.output_generators) == 2 + assert config.output_generators[1].registration.endswith(":_ConfigRegisteredOutputGenerator") + + +def test_load_config_rejects_duplicate_output_generator_names(tmp_path: Path) -> None: + config_path = tmp_path / "duplicate_output_generators.yml" + config_path.write_text( + "\n".join( + [ + "as_of_date: 2025-12-31", + "mosers_all_programs_xlsx: docs/N__A Data/MOSERS Counterparty Risk Summary 12-31-2025 - All Programs.xlsx", + "mosers_ex_trend_xlsx: docs/N__A Data/MOSERS Counterparty Risk Summary 12-31-2025 - Ex Trend.xlsx", + "mosers_trend_xlsx: docs/N__A Data/MOSERS Counterparty Risk Summary 12-31-2025 - Trend.xlsx", + "hist_all_programs_3yr_xlsx: docs/Ratings Instructiosns/Historical Counterparty Risk Graphs - All Programs 3 Year.xlsx", + "hist_ex_llc_3yr_xlsx: docs/Ratings Instructiosns/Historical Counterparty Risk Graphs - ex LLC 3 Year.xlsx", + "hist_llc_3yr_xlsx: docs/Ratings Instructiosns/Historical Counterparty Risk Graphs - LLC 3 Year.xlsx", + "monthly_pptx: docs/Ratings Instructiosns/Monthly Counterparty Exposure Report.pptx", + "output_generators:", + " - name: ppt_screenshot", + " registration: builtin:ppt_screenshot", + " stage: ppt_master", + " - name: PPT_SCREENSHOT", + " registration: builtin:ppt_screenshot", + " stage: ppt_master", + ] + ) + + "\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="duplicate name"): + load_config(config_path) def test_workflow_config_ppt_output_enabled_reflects_flag() -> None: diff --git a/tests/test_pipeline_run_outputs.py b/tests/test_pipeline_run_outputs.py index ea753d7f..d3f34eb6 100644 --- a/tests/test_pipeline_run_outputs.py +++ b/tests/test_pipeline_run_outputs.py @@ -9,11 +9,21 @@ import pytest import counter_risk.pipeline.run as run_module -from counter_risk.config import WorkflowConfig +from counter_risk.config import OutputGeneratorConfig, WorkflowConfig +from counter_risk.outputs.base import OutputContext from counter_risk.pipeline.ppt_naming import resolve_ppt_output_names from counter_risk.pipeline.ppt_validation import PptStandaloneValidationResult +class _ConfigRegisteredOutputGenerator: + name = "config_registered_output" + + def generate(self, *, context: OutputContext) -> tuple[Path, ...]: + output_path = context.run_dir / "config-registered-output.txt" + output_path.write_text("ok", encoding="utf-8") + return (output_path,) + + def _write_placeholder(path: Path, *, payload: bytes = b"fixture") -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(payload) @@ -238,3 +248,75 @@ def _validate_distribution(_path: Path) -> PptStandaloneValidationResult: ) assert called["validate_distribution"] == 1 + + +def test_write_outputs_skips_disabled_refresh_generator( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + run_dir = tmp_path / "run" + run_dir.mkdir(parents=True) + config = _build_config(tmp_path, screenshot_inputs={}) + config = config.model_copy( + update={ + "enable_screenshot_replacement": False, + "output_generators": tuple( + entry + for entry in config.output_generators + if entry.name in {"ppt_screenshot", "pdf_export"} + ), + } + ) + called: dict[str, int] = {"refresh": 0} + + def _unexpected_refresh(_path: Path) -> run_module.PptProcessingResult: + called["refresh"] += 1 + return run_module.PptProcessingResult(status=run_module.PptProcessingStatus.SUCCESS) + + monkeypatch.setattr(run_module, "_refresh_ppt_links", _unexpected_refresh) + + output_paths, _ppt_result = run_module._write_outputs( + run_dir=run_dir, + config=config, + as_of_date=date(2025, 12, 31), + warnings=[], + ) + + output_names = resolve_ppt_output_names(date(2025, 12, 31)) + assert called["refresh"] == 0 + assert (run_dir / output_names.master_filename) in output_paths + assert (run_dir / output_names.distribution_filename) in output_paths + + +def test_write_outputs_runs_config_registered_generator_without_pipeline_changes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + run_dir = tmp_path / "run" + run_dir.mkdir(parents=True) + config = _build_config(tmp_path, screenshot_inputs={}) + config = config.model_copy( + update={ + "enable_screenshot_replacement": False, + "output_generators": ( + *config.output_generators, + OutputGeneratorConfig( + name="config_registered_output", + registration="tests.test_pipeline_run_outputs:_ConfigRegisteredOutputGenerator", + stage="ppt_post_distribution", + enabled=True, + ), + ), + } + ) + + monkeypatch.setattr(run_module, "_refresh_ppt_links", lambda _path: True) + + output_paths, _ppt_result = run_module._write_outputs( + run_dir=run_dir, + config=config, + as_of_date=date(2025, 12, 31), + warnings=[], + ) + + custom_output = run_dir / "config-registered-output.txt" + assert custom_output in output_paths + assert custom_output.read_text(encoding="utf-8") == "ok" From a20f2be7c45c9aead9cafa4fe6674caf239fdaa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Feb 2026 04:45:59 +0000 Subject: [PATCH 18/18] fix: widen parsed_by_variant type to Mapping in _build_historical_workbook_output_generator The _require_parsed_by_variant helper returns Mapping[str, Mapping[str, Any]] but _build_historical_workbook_output_generator expected dict[str, dict[str, Any]], causing a mypy arg-type error. Widen to Mapping to match the actual return type. https://claude.ai/code/session_01JhCWWDJG8PqwaSbVPCGfm6 --- src/counter_risk/pipeline/run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/counter_risk/pipeline/run.py b/src/counter_risk/pipeline/run.py index 28dd9bb3..71727e6f 100644 --- a/src/counter_risk/pipeline/run.py +++ b/src/counter_risk/pipeline/run.py @@ -1719,7 +1719,7 @@ def _require_source_pptx(source_pptx: Path | None) -> Path: def _build_historical_workbook_output_generator( *, - parsed_by_variant: dict[str, dict[str, Any]], + parsed_by_variant: Mapping[str, Mapping[str, Any]], warnings: list[str], ) -> OutputGenerator: from counter_risk.outputs.historical_workbook import HistoricalWorkbookOutputGenerator