From a5d3684bdae4b03d68c000bb081870304f019dfc Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Thu, 12 Oct 2023 16:26:48 +0200 Subject: [PATCH] Improve coverage in .report, .util --- message_ix_models/project/ssp/__init__.py | 2 +- message_ix_models/report/__init__.py | 3 ++ message_ix_models/report/config.py | 2 +- message_ix_models/report/plot.py | 1 + message_ix_models/report/sim.py | 3 ++ message_ix_models/tests/project/test_ssp.py | 10 ++++++ .../tests/report/test_computations.py | 19 +++++++++++- message_ix_models/tests/report/test_config.py | 23 ++++++++++++++ message_ix_models/tests/test_report.py | 31 +++++++++++++++++-- message_ix_models/tests/util/test_click.py | 28 +++++++++++++++++ message_ix_models/tests/util/test_config.py | 5 +++ .../tests/util/test_scenarioinfo.py | 6 ++++ message_ix_models/util/click.py | 12 +++++-- message_ix_models/util/config.py | 7 +++++ message_ix_models/util/context.py | 2 +- pyproject.toml | 2 ++ 16 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 message_ix_models/tests/report/test_config.py diff --git a/message_ix_models/project/ssp/__init__.py b/message_ix_models/project/ssp/__init__.py index 0186f4fd4a..3ce98545a4 100644 --- a/message_ix_models/project/ssp/__init__.py +++ b/message_ix_models/project/ssp/__init__.py @@ -78,7 +78,7 @@ def gen_structures(context, **kwargs): ["SSP-Review-Phase-1.csv.gz", "SspDb_country_data_2013-06-12.csv.zip"] ), ) -def make_test_data(filename): +def make_test_data(filename): # pragma: no cover """Create random data for testing.""" from pathlib import Path diff --git a/message_ix_models/report/__init__.py b/message_ix_models/report/__init__.py index 4d5bd02bde..ad4620077e 100644 --- a/message_ix_models/report/__init__.py +++ b/message_ix_models/report/__init__.py @@ -16,7 +16,10 @@ from message_ix_models import Context, ScenarioInfo from message_ix_models.util._logging import mark_time +from .config import Config + __all__ = [ + "Config", "prepare_reporter", "register", "report", diff --git a/message_ix_models/report/config.py b/message_ix_models/report/config.py index 8395e912e3..b813826877 100644 --- a/message_ix_models/report/config.py +++ b/message_ix_models/report/config.py @@ -93,7 +93,7 @@ def use_file(self, file_path: Union[str, Path, None]) -> None: ) ) except StopIteration: - raise FileNotFoundError(f"Reporting configuration in {file_path}") + raise FileNotFoundError(f"Reporting configuration in '{file_path}(.yaml)'") # Store for genno to handle self.genno_config["path"] = path diff --git a/message_ix_models/report/plot.py b/message_ix_models/report/plot.py index 9eba19d9e9..3ca7a03445 100644 --- a/message_ix_models/report/plot.py +++ b/message_ix_models/report/plot.py @@ -210,3 +210,4 @@ class PrimaryEnergy1(FinalEnergy1): def callback(c: Computer, context: "Context") -> None: all_keys = [c.add(f"plot {p.basename}", p, "scenario") for p in PLOTS] c.add("plot all", all_keys) + log.info(f"Add 'plot all' collecting {len(all_keys)} plots") diff --git a/message_ix_models/report/sim.py b/message_ix_models/report/sim.py index 4318410918..55d8280218 100644 --- a/message_ix_models/report/sim.py +++ b/message_ix_models/report/sim.py @@ -273,6 +273,9 @@ def add_simulated_solution( mark_time() N = len(rep.graph) + # Ensure "scenario" is present in the graph + rep.graph.setdefault("scenario", None) + # Add simulated data data = data or dict() for name, item_info in SIMULATE_ITEMS.items(): diff --git a/message_ix_models/tests/project/test_ssp.py b/message_ix_models/tests/project/test_ssp.py index 5b84e95c30..b6f7291268 100644 --- a/message_ix_models/tests/project/test_ssp.py +++ b/message_ix_models/tests/project/test_ssp.py @@ -102,6 +102,11 @@ class TestSSPOriginal: ( dict(measure="POP", model="OECD Env-Growth"), dict(measure="GDP", model="OECD Env-Growth"), + # Excess keyword arguments + pytest.param( + dict(measure="GDP", model="OECD Env-Growth", foo="bar"), + marks=pytest.mark.xfail(raises=ValueError), + ), ), ) def test_prepare_computer(self, test_context, source, source_kw): @@ -141,6 +146,11 @@ class TestSSPUpdate: dict(measure="POP"), dict(measure="GDP", model="IIASA GDP 2023"), dict(measure="GDP", model="OECD ENV-Growth 2023"), + # Excess keyword arguments + pytest.param( + dict(measure="POP", foo="bar"), + marks=pytest.mark.xfail(raises=ValueError), + ), ), ) def test_prepare_computer(self, test_context, source, source_kw): diff --git a/message_ix_models/tests/report/test_computations.py b/message_ix_models/tests/report/test_computations.py index a8735076a9..84c223fa2c 100644 --- a/message_ix_models/tests/report/test_computations.py +++ b/message_ix_models/tests/report/test_computations.py @@ -1,7 +1,10 @@ +import re + +import pandas as pd import xarray as xr from genno import Quantity -from message_ix_models.report.computations import compound_growth +from message_ix_models.report.computations import compound_growth, filter_ts def test_compound_growth(): @@ -28,3 +31,17 @@ def test_compound_growth(): assert all(1.02**5 == r1.sel(t=2035) / r1.sel(t=2030)) assert all(1.0 == result.sel(x="x2")) + + +def test_filter_ts(): + df = pd.DataFrame([["foo"], ["bar"]], columns=["variable"]) + assert 2 == len(df) + + # Operator runs + result = filter_ts(df, re.compile(".(ar)")) + + # Only matching rows are returned + assert 1 == len(result) + + # Only the first match group in `expr` is preserved + assert {"ar"} == set(result.variable.unique()) diff --git a/message_ix_models/tests/report/test_config.py b/message_ix_models/tests/report/test_config.py new file mode 100644 index 0000000000..c84dcfcef8 --- /dev/null +++ b/message_ix_models/tests/report/test_config.py @@ -0,0 +1,23 @@ +import pytest + +from message_ix_models.report.config import Config + + +class TestConfig: + def test_use_file(self, tmp_path): + cfg = Config() + + # No effect + cfg.use_file(None) + + # Passing a missing path raises an exception + with pytest.raises( + FileNotFoundError, match="Reporting configuration in .*missing" + ): + cfg.use_file(tmp_path.joinpath("missing")) + + # Passing a file name that does not exist raises an exception + with pytest.raises( + FileNotFoundError, match=r"Reporting configuration in 'unknown\(.yaml\)'" + ): + cfg.use_file("unknown") diff --git a/message_ix_models/tests/test_report.py b/message_ix_models/tests/test_report.py index ea87ea1b8b..fb22477dce 100644 --- a/message_ix_models/tests/test_report.py +++ b/message_ix_models/tests/test_report.py @@ -159,6 +159,12 @@ def test_apply_units(request, test_context, regions): assert ["EUR_2005"] == df["unit"].unique() +@pytest.mark.xfail(reason="Incomplete", raises=TypeError) +def test_cli(mix_models_cli): + # TODO complete by providing a Scenario that is reportable (with solution) + mix_models_cli.assert_exit_0(["report"]) + + @pytest.mark.parametrize( "input, exp", ( @@ -203,8 +209,8 @@ def test_collapse(input, exp): pdt.assert_frame_equal(util.collapse(df_in), df_exp) -@MARK[0] -def test_add_simulated_solution(test_context, test_data_path): +def ss_reporter(): + """Reporter with a simulated solution for snapshot 0.""" from message_ix import Reporter rep = Reporter() @@ -216,7 +222,15 @@ def test_add_simulated_solution(test_context, test_data_path): path=package_data_path("test", "MESSAGEix-GLOBIOM_1.1_R11_no-policy_baseline"), ) - # out can be calculated using "output" and "ACT" from files in `path` + return rep + + +@MARK[0] +def test_add_simulated_solution(test_context, test_data_path): + # Simulated solution can be added to an empty Reporter + rep = ss_reporter() + + # "out" can be calculated using "output" and "ACT" from files in `path` result = rep.get("out:*") # Has expected dimensions and length @@ -237,3 +251,14 @@ def test_add_simulated_solution(test_context, test_data_path): hd="year", ) assert np.isclose(79.76478, value.item()) + + +def test_prepare_reporter(test_context): + rep = ss_reporter() + N = len(rep.graph) + + # prepare_reporter() works on the simulated solution + prepare_reporter(test_context, reporter=rep) + + # A number of keys were added + assert 14299 <= len(rep.graph) - N diff --git a/message_ix_models/tests/util/test_click.py b/message_ix_models/tests/util/test_click.py index 801371bc2b..81d9bb19b2 100644 --- a/message_ix_models/tests/util/test_click.py +++ b/message_ix_models/tests/util/test_click.py @@ -58,3 +58,31 @@ def func(ctx, ssp): # The value was stored on, and retrieved from, `ctx` assert "SSP2\n" == result.output + + +def test_urls_from_file(mix_models_cli, tmp_path): + """Test :func:`.urls_from_file` callback.""" + + # Create a hidden command and attach it to the CLI + @click.command(name="_test_store_context", hidden=True) + @common_params("urls_from_file") + @click.pass_obj + def func(ctx, **kwargs): + # Print the value stored on the Context object + print("\n".join([s.url for s in ctx.core.scenarios])) + + # Create a temporary file with some scenario URLs + text = """m/s#3 +foo/bar#5 +baz/qux#123 +""" + p = tmp_path.joinpath("scenarios.txt") + p.write_text(text) + + # Run the command, referring to the temporary file + with temporary_command(main, func): + result = mix_models_cli.assert_exit_0([func.name, f"--urls-from-file={p}"]) + + # Scenario URLs are parsed to ScenarioInfo objects, and then can be reconstructed → + # data is round-tripped + assert text == result.output diff --git a/message_ix_models/tests/util/test_config.py b/message_ix_models/tests/util/test_config.py index 0962a812b7..d48118e1e3 100644 --- a/message_ix_models/tests/util/test_config.py +++ b/message_ix_models/tests/util/test_config.py @@ -131,3 +131,8 @@ def test_replace(self, c): result = c.replace(foo_2="baz") assert result is not c assert "baz" == result.foo_2 + + def test_update(self, c): + """:meth:`.update` raises AttributeError.""" + with pytest.raises(AttributeError): + c.update(foo_4="") diff --git a/message_ix_models/tests/util/test_scenarioinfo.py b/message_ix_models/tests/util/test_scenarioinfo.py index ec748f852b..bb23b5c075 100644 --- a/message_ix_models/tests/util/test_scenarioinfo.py +++ b/message_ix_models/tests/util/test_scenarioinfo.py @@ -137,6 +137,12 @@ def test_from_scenario(self, test_context) -> None: assert 1963 == info.y0 assert [1963, 1964, 1965] == info.Y + def test_from_url(self): + si = ScenarioInfo.from_url("m/s#123") + assert "m" == si.model + assert "s" == si.scenario + assert 123 == si.version + @pytest.mark.parametrize( "input, expected", ( diff --git a/message_ix_models/util/click.py b/message_ix_models/util/click.py index 5602a06491..c6a1004dcf 100644 --- a/message_ix_models/util/click.py +++ b/message_ix_models/util/click.py @@ -5,7 +5,7 @@ import logging from datetime import datetime from pathlib import Path -from typing import Optional, Union +from typing import List, Optional, Union import click from click import Argument, Choice, Option @@ -86,9 +86,15 @@ def store_context(context: Union[click.Context, Context], param, value): return value -def urls_from_file(context: Union[click.Context, Context], param, value): +def urls_from_file( + context: Union[click.Context, Context], param, value +) -> List[ScenarioInfo]: """Callback to parse scenario URLs from `value`.""" - si = [] + si: List[ScenarioInfo] = [] + + if value is None: + return si + with click.open_file(value) as f: for line in f: si.append(ScenarioInfo.from_url(url=line)) diff --git a/message_ix_models/util/config.py b/message_ix_models/util/config.py index f162f14940..e037f42b61 100644 --- a/message_ix_models/util/config.py +++ b/message_ix_models/util/config.py @@ -110,6 +110,13 @@ def replace(self, **kwargs): ) def update(self, **kwargs): + """Update attributes in-place. + + Raises + ------ + AttributeError + Any of the `kwargs` are not fields in the data class. + """ # TODO use _munge_dict(); allow a positional argument for k, v in kwargs.items(): if not hasattr(self, k): diff --git a/message_ix_models/util/context.py b/message_ix_models/util/context.py index e1802d36ac..956b7955c9 100644 --- a/message_ix_models/util/context.py +++ b/message_ix_models/util/context.py @@ -72,7 +72,7 @@ def only(cls) -> "Context": def __init__(self, *args, **kwargs): from message_ix_models.model import Config as ModelConfig - from message_ix_models.report.config import Config as ReportConfig + from message_ix_models.report import Config as ReportConfig if len(_CONTEXTS) == 0: log.info("Create root Context") diff --git a/pyproject.toml b/pyproject.toml index 35e16c5c4b..94699ea3f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,8 @@ exclude_also = [ "@(abc\\.)?abstractmethod", # Imports only used by type checkers "if TYPE_CHECKING:", + # Requires message_data + "if HAS_MESSAGE_DATA:", ] [tool.mypy]