diff --git a/src/rompy/core/config.py b/src/rompy/core/config.py index 438b172..5d5c54e 100644 --- a/src/rompy/core/config.py +++ b/src/rompy/core/config.py @@ -44,3 +44,23 @@ class BaseConfig(RompyBaseModel): # noop call for config objects def __call__(self, *args, **kwargs): return self + + def render(self, context: dict, output_dir: Path | str): + """Render the configuration template to the output directory. + + This method orchestrates the template rendering process. The default implementation + uses cookiecutter rendering with the template and checkout defined on this config. + Subclasses can override this method to implement alternative rendering strategies + (e.g., direct file writing, Jinja2, custom logic). + + Args: + context: Full context dictionary. Expected to contain at least 'runtime' and 'config' keys. + output_dir: Target directory for rendered output. + + Returns: + str: Path to the staging directory (workspace) containing rendered files. + """ + # Import locally to avoid potential circular imports at module import time + from rompy.core.render import render as cookiecutter_render + + cookiecutter_render(context, self.template, output_dir, self.checkout) diff --git a/src/rompy/model.py b/src/rompy/model.py index 787bbec..84dcb08 100644 --- a/src/rompy/model.py +++ b/src/rompy/model.py @@ -18,7 +18,6 @@ from rompy.backends import BackendConfig from rompy.backends.config import BaseBackendConfig from rompy.core.config import BaseConfig -from rompy.core.render import render from rompy.core.time import TimeRange from rompy.core.types import RompyBaseModel from rompy.logging import get_logger @@ -255,10 +254,8 @@ def generate(self) -> str: cc_full["config"] = self.config # Render templates - logger.info(f"Rendering model templates to {self.output_dir}/{self.run_id}...") - staging_dir = render( - cc_full, self.config.template, self.output_dir, self.config.checkout - ) + logger.info(f"Rendering model configurations to {self.staging_dir}...") + self.config.render(cc_full, self.output_dir) logger.info("") # Use the log_box utility function @@ -269,8 +266,8 @@ def generate(self) -> str: logger=logger, add_empty_line=False, ) - logger.info(f"Model files generated at: {staging_dir}") - return staging_dir + logger.info(f"Model files generated at: {self.staging_dir}") + return self.staging_dir def zip(self) -> str: """Zip the input files for the model run @@ -366,9 +363,7 @@ def run(self, backend: BackendConfig, workspace_dir: Optional[str] = None) -> bo # Pass the config object and workspace_dir to the backend return backend_instance.run(self, config=backend, workspace_dir=workspace_dir) - def postprocess( - self, processor: "BasePostprocessorConfig", **kwargs - ) -> Dict[str, Any]: + def postprocess(self, processor, **kwargs) -> Dict[str, Any]: """ Postprocess the model outputs using the specified processor configuration. diff --git a/tests/test_config_render_delegation.py b/tests/test_config_render_delegation.py new file mode 100644 index 0000000..af51967 --- /dev/null +++ b/tests/test_config_render_delegation.py @@ -0,0 +1,46 @@ +import unittest.mock + +from rompy.model import ModelRun +from rompy.core.config import BaseConfig + + +def test_modelrun_delegates_to_config_render(tmp_path): + """ModelRun.generate() delegates rendering to config.render().""" + + # Setup + class TestConfig(BaseConfig): + def render(self, context: dict, output_dir): + # real implementation replaced by mock in the test + pass + + config = TestConfig(template="../rompy/templates/base", checkout=None) + model_run = ModelRun(run_id="test", output_dir=str(tmp_path), config=config) + + # Patch the class method so the instance call is intercepted. Inspect + # positional args to find the context and output_dir regardless of whether + # the mock was bound to the instance or the class. + # Patch by import path to avoid binding issues with Pydantic-generated classes + with unittest.mock.patch.object( + TestConfig, "render", return_value=None + ) as mock_render: + # Execute + result = model_run.generate() + + # Verify delegation + mock_render.assert_called_once() + call_args = mock_render.call_args + pos_args = call_args[0] + + # Locate the context dict (contains 'runtime') and the output_dir arg + context_arg = next( + (a for a in pos_args if isinstance(a, dict) and "runtime" in a), None + ) + output_dir_arg = next( + (a for a in pos_args if str(a) == str(model_run.output_dir)), None + ) + + assert isinstance(context_arg, dict) + assert "runtime" in context_arg + assert "config" in context_arg + assert output_dir_arg == model_run.output_dir + assert str(result) == str(tmp_path / "test")