Repository: csdms/model_metadata
Pull request #38: Add parameters keyword to stage function
Author: login: mcflugen
Created: 2025-05-21T22:16:31Z | Merged: 2025-05-22T17:34:37Z

I've added a new keyword, `parameters`, to the `stage` function. This allows a user to pass a set of parameters that will override the defaults. If not provided, default will be used as before.


Diff:
diff --git a/src/model_metadata/api.py b/src/model_metadata/api.py
index 55525f0..e8ff4b1 100644
--- a/src/model_metadata/api.py
+++ b/src/model_metadata/api.py
@@ -1,5 +1,9 @@
 from __future__ import annotations
 
+from collections.abc import Iterable
+from typing import Any
+
+from model_metadata.errors import UnknownKeyError
 from model_metadata.model_setup import FileSystemLoader
 from model_metadata.model_setup import OldFileSystemLoader
 from model_metadata.modelmetadata import ModelMetadata
@@ -52,7 +56,10 @@ def query(model: str, var: str) -> ModelMetadata:
 
 
 def stage(
-    model: str, dest: str = ".", old_style_templates: bool = False
+    model: str,
+    dest: str = ".",
+    old_style_templates: bool = False,
+    parameters: dict[str, Any] | None = None,
 ) -> tuple[str, ...]:
     """Stage a model by setting up its input files.
 
@@ -62,18 +69,39 @@ def stage(
         The model is interpreted either as a path to a folder that
         contains metadata, the name of a model component, or a
         model object.
-    dest : str
+    dest : str, optional
         Path to a folder within which to stage the model.
+    parameters : dict[str, Any], optional
+        A dictionary of parameters that overrides the default
+        values.
     """
+    parameters = {} if parameters is None else parameters
+
     defaults = {}
     mmd = ModelMetadata.find(model)
     meta = ModelMetadata(mmd)
     for param, item in meta.parameters.items():
         defaults[param] = item["value"]["default"]
 
+    try:
+        _check_for_unknown_keys(defaults.keys(), parameters.keys())
+    except UnknownKeyError as e:
+        e.add_note(
+            f"valid parameter{'s' if len(defaults) > 1 else ''}:"
+            f" {', '.join(sorted(repr(k) for k in defaults))}"
+        )
+        raise
+
+    defaults = {**defaults, **parameters}
+
     if old_style_templates:
         manifest = OldFileSystemLoader(mmd).stage_all(dest, **defaults)
     else:
         manifest = FileSystemLoader(mmd).stage_all(dest, **defaults)
 
     return manifest
+
+
+def _check_for_unknown_keys(allowed: Iterable[str], user: Iterable[str]) -> None:
+    if unknown_keys := (set(user) - set(allowed)):
+        raise UnknownKeyError(unknown_keys)
diff --git a/src/model_metadata/errors.py b/src/model_metadata/errors.py
index 7c8739b..08fbca6 100644
--- a/src/model_metadata/errors.py
+++ b/src/model_metadata/errors.py
@@ -1,6 +1,8 @@
 #! /usr/bin/env python
 from __future__ import annotations
 
+from collections.abc import Iterable
+
 
 class ModelMetadataError(Exception):
     """Base error for model_metadata package."""
@@ -55,3 +57,16 @@ def __init__(self, entry_point: str, msg: str | None = None):
 
     def __str__(self) -> str:
         return self._entry_point + f": {self._msg}" if self._msg else ""
+
+
+class UnknownKeyError(ModelMetadataError):
+    """Raise if a dictionary contains one or more unrecognized keys."""
+
+    def __init__(self, unknown: Iterable[str]) -> None:
+        super().__init__(*sorted(set(unknown)))
+
+    def __str__(self) -> str:
+        return (
+            f"unknown key{'s' if len(self.args) > 1 else ''}:"
+            f" {', '.join(repr(key) for key in self.args)}"
+        )
diff --git a/tests/api_test.py b/tests/api_test.py
index da6a1cd..11a29fe 100644
--- a/tests/api_test.py
+++ b/tests/api_test.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import itertools
 import os
 import pathlib
 
@@ -10,6 +11,7 @@
 from model_metadata.errors import MetadataNotFoundError
 from model_metadata.errors import MissingSectionError
 from model_metadata.errors import MissingValueError
+from model_metadata.errors import UnknownKeyError
 
 
 class Model:
@@ -85,6 +87,26 @@ def test_stage_is_filled_out(tmpdir, shared_datadir):
         assert "}}" not in contents
 
 
+def test_stage_with_parameters(tmpdir, shared_datadir):
+    with tmpdir.as_cwd():
+        stage(str(shared_datadir), parameters={"run_duration": 999})
+
+        file_contains_runtime = False
+        with open("child.in") as fp:
+            for this, next_ in itertools.pairwise(fp):
+                file_contains_runtime = this.startswith("RUNTIME")
+                if file_contains_runtime:
+                    assert next_.startswith("999")
+                    break
+        assert file_contains_runtime
+
+
+@pytest.mark.parametrize("params", ({"foo": "bar"}, {"foo": "bar", "baz": "foobar"}))
+def test_stage_with_unknown_parameters(tmpdir, shared_datadir, params):
+    with tmpdir.as_cwd(), pytest.raises(UnknownKeyError):
+        stage(str(shared_datadir), parameters=params)
+
+
 def test_query_with_bad_section(shared_datadir):
     with pytest.raises(MissingSectionError):
         query(str(shared_datadir), "not-a-section.version")


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR adds a new `parameters` keyword to the `stage()` function, allowing callers to override default model parameters and validating any unexpected keys.

- Introduce `UnknownKeyError` in errors module for unrecognized parameter keys  
- Extend `stage()` signature to accept `parameters`, merge with defaults, and check for unknown keys  
- Add `_check_for_unknown_keys` helper and use `Exception.add_note` for clearer guidance

### Reviewed Changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

| File                         | Description                                             |
| ---------------------------- | ------------------------------------------------------- |
| src/model_metadata/errors.py | Add `UnknownKeyError` to flag unknown dictionary keys   |
| src/model_metadata/api.py    | Update `stage()` to accept and validate `parameters`    |


<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/model_metadata/api.py:62**
* Add unit tests for the new `parameters` argument in `stage()`, covering both successful default overrides and raising `UnknownKeyError` for invalid keys to ensure this behavior is verified.
```
parameters: dict[str, Any] | None = None,
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-05-21T22:28:29Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR adds a new keyword, parameters, to the stage function so that users can override default parameters when staging a model.  
- Introduces tests to verify the handling of valid and unknown parameters.  
- Adds a new UnknownKeyError in errors.py and updates the stage function in api.py to check for and merge provided parameters with defaults.

### Reviewed Changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

| File                     | Description                                                    |
| ------------------------ | -------------------------------------------------------------- |
| tests/api_test.py        | Adds tests that validate parameter passing and error handling. |
| src/model_metadata/errors.py | Introduces a custom error type for unknown parameter keys.       |
| src/model_metadata/api.py | Updates stage function with parameter support and validation.  |




state: COMMENTED
author_association: NONE
submitted_at: 2025-05-22T16:10:35Z

Review comments:
diff_hunk: @@ -55,3 +57,18 @@ def __init__(self, entry_point: str, msg: str | None = None):
 
     def __str__(self) -> str:
         return self._entry_point + f": {self._msg}" if self._msg else ""
+
+
+class UnknownKeyError(ModelMetadataError):
+    """Raise if a dictionary contains an unrecognized key."""
+
+    def __init__(self, unknown: Iterable[str] | str) -> None:
+        if isinstance(unknown, str):
+            unknown = [unknown]
path: src/model_metadata/errors.py
user: login: Copilot
body: Consider calling `super().__init__()` (or `Exception.__init__`) to properly initialize the base Exception, ensuring `error.args` is populated as expected.
```suggestion
            unknown = [unknown]
        super().__init__(unknown)
```
created_at: 2025-05-21T22:28:28Z
author_association: NONE
diff_hunk: @@ -62,18 +69,39 @@ def stage(
         The model is interpreted either as a path to a folder that
         contains metadata, the name of a model component, or a
         model object.
-    dest : str
+    dest : str, optional
         Path to a folder within which to stage the model.
path: src/model_metadata/api.py
user: login: Copilot
body: The `old_style_templates` parameter in `stage()` is not documented in the docstring. Please add its description to keep the API docs in sync with the signature.
```suggestion
        Path to a folder within which to stage the model.
    old_style_templates : bool, optional
        If True, use the old-style template loader. Defaults to False.
```
created_at: 2025-05-21T22:28:29Z
author_association: NONE
diff_hunk: @@ -62,18 +69,41 @@ def stage(
         The model is interpreted either as a path to a folder that
         contains metadata, the name of a model component, or a
         model object.
-    dest : str
+    dest : str, optional
         Path to a folder within which to stage the model.
+    parameters : dict[str, Any], optional
+        A dictionary of parameters that overrides the default
+        values.
     """
+    parameters = {} if parameters is None else parameters
+
     defaults = {}
     mmd = ModelMetadata.find(model)
     meta = ModelMetadata(mmd)
     for param, item in meta.parameters.items():
         defaults[param] = item["value"]["default"]
 
+    try:
+        _check_for_unknown_keys(defaults.keys(), parameters.keys())
+    except UnknownKeyError as e:
+        e.add_note(
+            f"valid parameter{'s' if len(defaults) > 1 else ''}:"
+            f" {', '.join(sorted(repr(k) for k in defaults))}"
+        )
+        raise
+
+    defaults = {**defaults, **parameters}
+
     if old_style_templates:
         manifest = OldFileSystemLoader(mmd).stage_all(dest, **defaults)
     else:
         manifest = FileSystemLoader(mmd).stage_all(dest, **defaults)
 
     return manifest
+
+
+def _check_for_unknown_keys(allowed: Iterable[str], user: Iterable[str]) -> None:
+    if unknown_keys := (set(user) - set(allowed)):
+        raise UnknownKeyError(
+            unknown_keys if len(unknown_keys) > 1 else unknown_keys.pop()
+        )
path: src/model_metadata/api.py
user: login: Copilot
body: [nitpick] Consider always passing a collection to UnknownKeyError to maintain consistency in error handling; for example, convert the result to a list regardless of the number of unknown keys.
```suggestion
        raise UnknownKeyError(list(unknown_keys))
```
created_at: 2025-05-22T16:10:35Z
author_association: NONE
diff_hunk: @@ -85,6 +87,25 @@ def test_stage_is_filled_out(tmpdir, shared_datadir):
         assert "}}" not in contents
 
 
+def test_stage_with_parameters(tmpdir, shared_datadir):
+    with tmpdir.as_cwd():
+        stage(str(shared_datadir), parameters={"run_duration": 999})
+
+        file_contains_runtime = False
+        with open("child.in") as fp:
+            for this, next_ in itertools.pairwise(fp):
+                if file_contains_runtime := this.startswith("RUNTIME"):
path: tests/api_test.py
user: login: Copilot
body: [nitpick] Consider separating the assignment from the condition to improve readability, e.g., assign the boolean value on a dedicated line before the if statement.
```suggestion
                file_contains_runtime = this.startswith("RUNTIME")
                if file_contains_runtime:
```
created_at: 2025-05-22T16:10:35Z
author_association: NONE

PR comments:
user: login: coveralls
created_at: 2025-05-21T22:20:04Z
body: ## Pull Request Test Coverage Report for [Build 15173989299](https://coveralls.io/builds/73787652)


### Details

* **13** of **21**   **(61.9%)**  changed or added relevant lines in **2** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage decreased (**-0.3%**) to **73.562%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [src/model_metadata/api.py](https://coveralls.io/builds/73787652/source?filename=src%2Fmodel_metadata%2Fapi.py#L88) | 9 | 13 | 69.23%
| [src/model_metadata/errors.py](https://coveralls.io/builds/73787652/source?filename=src%2Fmodel_metadata%2Ferrors.py#L66) | 4 | 8 | 50.0%
<!-- | **Total:** | **13** | **21** | **61.9%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/73787652/badge)](https://coveralls.io/builds/73787652) |
| :-- | --: |
| Change from base [Build 11354437340](https://coveralls.io/builds/70352142): |  -0.3% |
| Covered Lines: | 665 |
| Relevant Lines: | 904 |

---
##### 💛  - [Coveralls](https://coveralls.io)

author_association: NONE
user: login: codecov[bot]
created_at: 2025-05-22T05:34:24Z
body: ## [Codecov](https://app.codecov.io/gh/csdms/model_metadata/pull/38?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=csdms) Report
Attention: Patch coverage is `94.44444%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 74.91%. Comparing base [(`6925e55`)](https://app.codecov.io/gh/csdms/model_metadata/commit/6925e55ff41423f321d4603bc54486142b4b160b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=csdms) to head [(`1b054f1`)](https://app.codecov.io/gh/csdms/model_metadata/commit/1b054f1b0f48765cccdac03b0ddbb82da9c8cf4c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=csdms).
> Report is 5 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/csdms/model_metadata/pull/38?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=csdms) | Patch % | Lines |
|---|---|---|
| [src/model\_metadata/errors.py](https://app.codecov.io/gh/csdms/model_metadata/pull/38?src=pr&el=tree&filepath=src%2Fmodel_metadata%2Ferrors.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=csdms#diff-c3JjL21vZGVsX21ldGFkYXRhL2Vycm9ycy5weQ==) | 83.33% | [1 Missing :warning: ](https://app.codecov.io/gh/csdms/model_metadata/pull/38?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=csdms) |
| [tests/api\_test.py](https://app.codecov.io/gh/csdms/model_metadata/pull/38?src=pr&el=tree&filepath=tests%2Fapi_test.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=csdms#diff-dGVzdHMvYXBpX3Rlc3QucHk=) | 94.11% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/csdms/model_metadata/pull/38?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=csdms) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      #38      +/-   ##
==========================================
+ Coverage   74.25%   74.91%   +0.65%     
==========================================
  Files          20       20              
  Lines        1080     1116      +36     
  Branches      103      106       +3     
==========================================
+ Hits          802      836      +34     
- Misses        232      233       +1     
- Partials       46       47       +1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/csdms/model_metadata/pull/38?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=csdms).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=csdms).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE