Skip to content

Commit e300233

Browse files
committed
Move .report.CALLBACKS → .report.Config.callback
- Global variable becomes an instance variable for .report.Config instances. This allows the use of different callbacks within the same program scope. - Retain same defaults. - Mark .report.register() function as deprecated. - Adjust all usage of register(). - Remove fixture preserve_report_callbacks (no longer needed) and all usage. - Adjust and add tests.
1 parent 9ded015 commit e300233

File tree

11 files changed

+140
-111
lines changed

11 files changed

+140
-111
lines changed

message_ix_models/model/transport/testing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ def simulated_solution(request, context) -> Reporter:
176176
add_simulated_solution(rep, info, data)
177177

178178
# Register the callback to set up transport reporting
179-
message_ix_models.report.register(callback)
179+
context.report.register(callback)
180180

181181
# Prepare the reporter
182182
with silence_log("genno", logging.CRITICAL):

message_ix_models/model/transport/workflow.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ def generate(
187187
from message_ix_models import Workflow
188188
from message_ix_models.model.workflow import solve
189189
from message_ix_models.project.ssp import SSP_2024
190-
from message_ix_models.report import register, report
190+
from message_ix_models.report import report
191191

192192
from . import build
193193
from .config import Config, get_cl_scenario
@@ -205,8 +205,8 @@ def generate(
205205
Config.from_context(context, options=options)
206206

207207
# Set the default .report.Config key for ".* reported" steps
208-
register("model.transport")
209208
context.report.key = report_key
209+
context.report.register("model.transport")
210210

211211
# Create the workflow
212212
wf = Workflow(context)

message_ix_models/report/__init__.py

Lines changed: 16 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import logging
2-
from collections.abc import Callable
32
from contextlib import nullcontext
43
from copy import deepcopy
54
from functools import partial
6-
from importlib import import_module
75
from operator import itemgetter
86
from pathlib import Path
9-
from typing import Optional, Union
7+
from typing import TYPE_CHECKING, Optional, Union
108
from warnings import warn
119

1210
import genno.config
@@ -21,6 +19,9 @@
2119

2220
from .config import Config
2321

22+
if TYPE_CHECKING:
23+
from .config import Callback
24+
2425
__all__ = [
2526
"Config",
2627
"prepare_reporter",
@@ -43,10 +44,6 @@ def _(c: Reporter, info):
4344
pass
4445

4546

46-
#: List of callbacks for preparing the Reporter.
47-
CALLBACKS: list[Callable] = []
48-
49-
5047
@genno.config.handles("iamc")
5148
def iamc(c: Reporter, info):
5249
"""Handle one entry from the ``iamc:`` config section.
@@ -119,67 +116,20 @@ def iamc(c: Reporter, info):
119116
c.add("concat", f"{info['variable']}::iamc", *keys)
120117

121118

122-
def register(name_or_callback: Union[Callable, str]) -> Optional[str]:
123-
"""Register a callback function for :meth:`prepare_reporter`.
124-
125-
Each registered function is called by :meth:`prepare_reporter`, in order to add or
126-
modify reporting keys. Specific model variants and projects can register a callback
127-
to extend the reporting graph.
128-
129-
Callback functions must take two arguments: the Reporter, and a :class:`.Context`:
130-
131-
.. code-block:: python
132-
133-
from message_ix.report import Reporter
134-
from message_ix_models import Context
135-
from message_ix_models.report import register
136-
137-
def cb(rep: Reporter, ctx: Context):
138-
# Modify `rep` by calling its methods ...
139-
pass
119+
def register(name_or_callback: Union["Callback", str]) -> Optional[str]:
120+
"""Deprecated alias for :meth:`.report.Config.register`.
140121
141-
register(cb)
142-
143-
Parameters
144-
----------
145-
name_or_callback
146-
If a string, this may be a submodule of :mod:`.message_ix_models`, or
147-
:mod:`message_data`, in which case the function
148-
``{message_data,message_ix_models}.{name}.report.callback`` is used. Or, it may
149-
be a fully-resolved package/module name, in which case ``{name}.callback`` is
150-
used. If a callable (function), it is used directly.
122+
This version uses :meth:`Context.get_instance()` to get the 0-th Context, and calls
123+
that method.
151124
"""
152-
if isinstance(name_or_callback, str):
153-
# Resolve a string
154-
candidates = [
155-
# As a fully-resolved package/module name
156-
name_or_callback,
157-
# As a submodule of message_ix_models
158-
f"message_ix_models.{name_or_callback}.report",
159-
# As a submodule of message_data
160-
f"message_data.{name_or_callback}.report",
161-
]
162-
mod = None
163-
for name in candidates:
164-
try:
165-
mod = import_module(name)
166-
except ModuleNotFoundError:
167-
continue
168-
else:
169-
break
170-
if mod is None:
171-
raise ModuleNotFoundError(" or ".join(candidates))
172-
callback = mod.callback
173-
else:
174-
callback = name_or_callback
175-
name = callback.__name__
176-
177-
if callback in CALLBACKS:
178-
log.info(f"Already registered: {callback}")
179-
return None
125+
warn(
126+
"message_ix_models.report.register(…) function; use the method "
127+
".report.Config.register(…) or Context.report.register(…) instead",
128+
DeprecationWarning,
129+
stacklevel=2,
130+
)
180131

181-
CALLBACKS.append(callback)
182-
return name
132+
return Context.get_instance(0).report.register(name_or_callback)
183133

184134

185135
def log_before(context, rep, key) -> None:
@@ -367,7 +317,7 @@ def prepare_reporter(
367317
rep.configure(model=deepcopy(context.model))
368318

369319
# Apply callbacks for other modules which define additional reporting computations
370-
for callback in CALLBACKS:
320+
for callback in context.report.callback:
371321
callback(rep, context)
372322

373323
key = context.report.key
@@ -415,7 +365,3 @@ def defaults(rep: Reporter, context: Context) -> None:
415365
rep.add(*comp, strict=True)
416366
except KeyExistsError:
417367
pass # message_ix > 3.7.0; these are already defined
418-
419-
420-
register(defaults)
421-
register("message_ix_models.report.plot")

message_ix_models/report/cli.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,8 @@
1010

1111
def _modules_arg(context, param, value):
1212
"""--module/-m: load extra reporting config from modules."""
13-
from . import register
14-
1513
for m in filter(len, value.split(",")):
16-
name = register(m)
14+
name = context.report.register(m)
1715
log.info(f"Registered reporting from {name}")
1816

1917

message_ix_models/report/config.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,33 @@
11
import logging
2+
from collections.abc import Callable
23
from dataclasses import InitVar, dataclass, field
4+
from importlib import import_module
35
from pathlib import Path
46
from typing import TYPE_CHECKING, Optional, Union
57

68
from message_ix_models.util import local_data_path, package_data_path
79
from message_ix_models.util.config import ConfigHelper
810

911
if TYPE_CHECKING:
12+
import genno
1013
from genno.core.key import KeyLike
1114

15+
from message_ix_models.util.context import Context
16+
1217
log = logging.getLogger(__name__)
1318

19+
#: Type signature of callback functions referenced by :attr:`.Config.callback` and
20+
#: used by :func:`.prepare_reporter`.
21+
Callback = Callable[["genno.Computer", "Context"], None]
22+
23+
24+
def _default_callbacks() -> list[Callback]:
25+
from message_ix_models.report import plot
26+
27+
from . import defaults
28+
29+
return [defaults, plot.callback]
30+
1431

1532
@dataclass
1633
class Config(ConfigHelper):
@@ -20,6 +37,28 @@ class Config(ConfigHelper):
2037
respected.
2138
"""
2239

40+
#: List of callbacks for preparing the :class:`.Reporter`.
41+
#:
42+
#: Each registered function is called by :meth:`prepare_reporter`, in order to add
43+
#: or modify reporting keys. Specific model variants and projects can register a
44+
#: callback to extend the reporting graph.
45+
#:
46+
#: Callback functions must take two arguments: the Computer/Reporter, and a
47+
#: :class:`.Context`:
48+
#:
49+
#: .. code-block:: python
50+
#:
51+
#: from message_ix.report import Reporter
52+
#: from message_ix_models import Context
53+
#: from message_ix_models.report import register
54+
#:
55+
#: def cb(rep: Reporter, ctx: Context) -> None:
56+
#: # Modify `rep` by calling its methods ...
57+
#: pass
58+
#:
59+
#: context.report.register(cb)
60+
callback: list[Callback] = field(default_factory=_default_callbacks)
61+
2362
#: Shorthand to call :func:`use_file` on a new instance.
2463
from_file: InitVar[Optional[Path]] = package_data_path("report", "global.yaml")
2564

@@ -49,9 +88,54 @@ class Config(ConfigHelper):
4988
legacy: dict = field(default_factory=lambda: dict(use=False, merge_hist=True))
5089

5190
def __post_init__(self, from_file, _legacy) -> None:
91+
# Handle InitVars
5292
self.use_file(from_file)
5393
self.legacy.update(use=_legacy)
5494

95+
def register(self, name_or_callback: Union[Callback, str]) -> Optional[str]:
96+
"""Register a :attr:`callback` function for :func:`prepare_reporter`.
97+
98+
Parameters
99+
----------
100+
name_or_callback
101+
If a callable (function), it is used directly.
102+
103+
If a string, it may name a submodule of :mod:`.message_ix_models`, or
104+
:mod:`message_data`, in which case the function
105+
:py:`{message_data,message_ix_models}.{name}.report.callback` is used. Or,
106+
it may be a fully-resolved package/module name, in which case
107+
:py:`{name}.callback` is used.
108+
"""
109+
110+
if isinstance(name_or_callback, str):
111+
# Resolve a string
112+
candidates = [
113+
name_or_callback, # A fully-resolved package/module name
114+
f"message_ix_models.{name_or_callback}.report", # A submodule here
115+
f"message_data.{name_or_callback}.report", # A message_data submodule
116+
]
117+
mod = None
118+
for name in candidates:
119+
try:
120+
mod = import_module(name)
121+
except ModuleNotFoundError:
122+
continue
123+
else:
124+
break
125+
if mod is None:
126+
raise ModuleNotFoundError(" or ".join(candidates))
127+
callback = mod.callback
128+
else:
129+
callback = name_or_callback
130+
name = callback.__name__
131+
132+
if callback in self.callback:
133+
log.info(f"Already registered: {callback}")
134+
return None
135+
136+
self.callback.append(callback)
137+
return name
138+
55139
def set_output_dir(self, arg: Optional[Path]) -> None:
56140
"""Set :attr:`output_dir`, the output directory.
57141

message_ix_models/testing/__init__.py

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -51,24 +51,6 @@ def pytest_addoption(parser):
5151
# Fixtures
5252

5353

54-
@pytest.fixture(scope="function")
55-
def preserve_report_callbacks():
56-
"""Protect :data:`.report.CALLBACKS` from effects of a test function.
57-
58-
Use this fixture for test functions that call :func:`.report.register` to avoid
59-
changing the global/default configuration for other tests.
60-
"""
61-
62-
from message_ix_models import report
63-
64-
try:
65-
tmp = report.CALLBACKS.copy()
66-
yield
67-
finally:
68-
report.CALLBACKS.clear()
69-
report.CALLBACKS.extend(tmp)
70-
71-
7254
@pytest.fixture(scope="session")
7355
def session_context(pytestconfig, tmp_env):
7456
"""A :class:`.Context` connected to a temporary, in-memory database.
@@ -148,9 +130,6 @@ def test_context(request, session_context):
148130
"""A copy of :func:`session_context` scoped to one test function."""
149131
ctx = deepcopy(session_context)
150132

151-
# Ensure there is a report key
152-
ctx.setdefault("report", dict())
153-
154133
yield ctx
155134

156135
ctx.delete()

message_ix_models/tests/model/transport/test_build.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,6 @@ def test_build_bare_res(
122122
# "ixmp://local/MESSAGEix-Transport on ENGAGE_SSP2_v4.1.7/baseline",
123123
),
124124
)
125-
@pytest.mark.usefixtures("preserve_report_callbacks")
126125
def test_build_existing(tmp_path, test_context, url, solve=False):
127126
"""Test that model.transport.build works on certain existing scenarios.
128127

message_ix_models/tests/model/transport/test_demand.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,6 @@ def test_urban_rural_shares(test_context, tmp_path, regions, years, pop_scen):
317317

318318
@MARK[7]
319319
@build.get_computer.minimum_version
320-
@pytest.mark.usefixtures("preserve_report_callbacks")
321320
@pytest.mark.parametrize(
322321
"nodes, target",
323322
[

message_ix_models/tests/model/transport/test_report.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ def test_configure_legacy():
5656

5757
@MARK[7]
5858
@build.get_computer.minimum_version
59-
@pytest.mark.usefixtures("preserve_report_callbacks")
6059
@pytest.mark.parametrize(
6160
"regions, years",
6261
(
@@ -75,9 +74,7 @@ def test_configure_legacy():
7574
def test_report_bare_solved(request, test_context, tmp_path, regions, years):
7675
"""Run MESSAGEix-Transport–specific reporting."""
7776
from message_ix_models.model.transport.report import callback
78-
from message_ix_models.report import Config, register
79-
80-
register(callback)
77+
from message_ix_models.report import Config
8178

8279
# Update configuration
8380
# key = "transport all" # All including plots, etc.
@@ -87,6 +84,7 @@ def test_report_bare_solved(request, test_context, tmp_path, regions, years):
8784
years=years,
8885
report=Config("global.yaml", key=key, output_dir=tmp_path),
8986
)
87+
test_context.report.register(callback)
9088

9189
# Built and (optionally) solved scenario. dummy supply data is necessary for the
9290
# scenario to be feasible without any other contents.
@@ -114,7 +112,7 @@ def quiet_genno(caplog):
114112

115113
@MARK[7]
116114
@build.get_computer.minimum_version
117-
@mark.usefixtures("quiet_genno", "preserve_report_callbacks")
115+
@mark.usefixtures("quiet_genno")
118116
def test_simulated_solution(request, test_context, regions="R12", years="B"):
119117
""":func:`message_ix_models.report.prepare_reporter` works on the simulated data."""
120118
test_context.update(regions=regions, years=years)
@@ -136,7 +134,7 @@ def test_simulated_solution(request, test_context, regions="R12", years="B"):
136134

137135
@pytest.mark.xfail(condition=GHA, reason="Temporary, for #213; fails on GitHub Actions")
138136
@build.get_computer.minimum_version
139-
@mark.usefixtures("quiet_genno", "preserve_report_callbacks")
137+
@mark.usefixtures("quiet_genno")
140138
@pytest.mark.parametrize(
141139
"plot_name",
142140
# # All plots
@@ -161,7 +159,6 @@ def test_plot_simulated(request, test_context, plot_name, regions="R12", years="
161159

162160
@pytest.mark.xfail(condition=GHA, reason="Temporary, for #213; fails on GitHub Actions")
163161
@sim.to_simulate.minimum_version
164-
@pytest.mark.usefixtures("preserve_report_callbacks")
165162
def test_iamc_simulated(
166163
request, tmp_path_factory, test_context, regions="R12", years="B"
167164
) -> None:

0 commit comments

Comments
 (0)