Skip to content

Commit 91d6302

Browse files
authored
Merge pull request #295 from iiasa/issue/294
Confirm support for minimum versions
2 parents 43ce610 + a5626b8 commit 91d6302

File tree

15 files changed

+83
-40
lines changed

15 files changed

+83
-40
lines changed

.github/workflows/pytest.yaml

+3-1
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ jobs:
7474
# - Versions of ixmp and message_ix to test.
7575
# - Latest supported Python version for those or other dependencies.
7676
# Minimum version given in pyproject.toml + earlier version of Python
77-
- { upstream: v3.6.0, python: "3.11" } # 2022-08-18
77+
# For this job only, the oldest version of Python supported by message-ix-models
78+
- { upstream: v3.6.0, python: "3.9" } # Released 2022-08-18
7879
- { upstream: v3.7.0, python: "3.11" } # 2023-05-17
7980
- { upstream: v3.8.0, python: "3.12" } # 2024-01-12
8081
# Latest released version + latest released Python
@@ -133,6 +134,7 @@ jobs:
133134
v, result = "${{ matrix.version.upstream }}".replace("main", "vmain"), []
134135
for condition, dependency in (
135136
(v <= "v3.6.0", "dask < 2024.3.0"), # dask[dataframe] >= 2024.3.0 requires dask-expr and in turn pandas >= 2.0 (#156)
137+
(v <= "v3.6.0", "numpy < 2.0"),
136138
(v <= "v3.6.0", "pandas < 2.0"),
137139
(v >= "v3.7.0", "dask[dataframe] < 2024.11.0"), # dask >= 2024.11.0 changes handling of dict (will be addressed in #225)
138140
(v <= "v3.7.0", "genno < 1.25"), # Upstream versions < 3.8.0 import genno.computations, removed in 1.25.0 (#156)

message_ix_models/model/material/build.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
from collections.abc import Mapping
3-
from typing import Any
3+
from typing import Any, Optional
44

55
import message_ix
66
import pandas as pd
@@ -90,7 +90,7 @@ def build(
9090
scenario: message_ix.Scenario,
9191
old_calib: bool,
9292
modify_existing_constraints: bool = True,
93-
iea_data_path: str | None = None,
93+
iea_data_path: Optional[str] = None,
9494
) -> message_ix.Scenario:
9595
"""Set up materials accounting on `scenario`."""
9696
node_suffix = context.model.regions

message_ix_models/model/material/data_petro.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from collections import defaultdict
2+
from typing import Union
23

34
import message_ix
45
import pandas as pd
@@ -233,7 +234,7 @@ def assign_input_outpt(
233234
split: str,
234235
param_name: str,
235236
regions: pd.DataFrame,
236-
val: float | int,
237+
val: Union[float, int],
237238
t: str,
238239
rg: str,
239240
global_region: str,

message_ix_models/model/material/data_util.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
22
from collections.abc import Mapping
33
from functools import lru_cache
4-
from typing import TYPE_CHECKING, Literal
4+
from typing import TYPE_CHECKING, Literal, Optional
55

66
import ixmp
77
import message_ix
@@ -1013,7 +1013,7 @@ def read_iea_tec_map(tec_map_fname: str) -> pd.DataFrame:
10131013

10141014

10151015
def get_hist_act_data(
1016-
map_fname: str, years: list or None = None, iea_data_path: str | None = None
1016+
map_fname: str, years: list or None = None, iea_data_path: Optional[str] = None
10171017
) -> pd.DataFrame:
10181018
"""
10191019
reads IEA DB, maps and aggregates variables to MESSAGE technologies

message_ix_models/model/material/material_demand/material_demand_calc.py

+9-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
from pathlib import Path
3-
from typing import Literal
3+
from typing import Literal, Union
44

55
import message_ix
66
import numpy as np
@@ -57,12 +57,12 @@
5757
log = logging.getLogger(__name__)
5858

5959

60-
def steel_function(x: pd.DataFrame | float, a: float, b: float, m: float):
60+
def steel_function(x: Union[pd.DataFrame, float], a: float, b: float, m: float):
6161
gdp_pcap, del_t = x
6262
return a * np.exp(b / gdp_pcap) * (1 - m) ** del_t
6363

6464

65-
def cement_function(x: pd.DataFrame | float, a: float, b: float):
65+
def cement_function(x: Union[pd.DataFrame, float], a: float, b: float):
6666
gdp_pcap = x[0]
6767
return a * np.exp(b / gdp_pcap)
6868

@@ -92,12 +92,14 @@ def cement_function(x: pd.DataFrame | float, a: float, b: float):
9292
}
9393

9494

95-
def gompertz(phi: float, mu: float, y: pd.DataFrame | float, baseyear: int = 2020):
95+
def gompertz(
96+
phi: float, mu: float, y: Union[pd.DataFrame, float], baseyear: int = 2020
97+
):
9698
return 1 - np.exp(-phi * np.exp(-mu * (y - baseyear)))
9799

98100

99101
def read_timer_pop(
100-
datapath: str | Path, material: Literal["cement", "steel", "aluminum"]
102+
datapath: Union[str, Path], material: Literal["cement", "steel", "aluminum"]
101103
):
102104
df_population = pd.read_excel(
103105
f"{datapath}/{material_data[material]['dir']}{material_data[material]['file']}",
@@ -115,7 +117,7 @@ def read_timer_pop(
115117

116118

117119
def read_timer_gdp(
118-
datapath: str | Path, material: Literal["cement", "steel", "aluminum"]
120+
datapath: Union[str, Path], material: Literal["cement", "steel", "aluminum"]
119121
):
120122
# Read GDP per capita data
121123
df_gdp = pd.read_excel(
@@ -165,7 +167,7 @@ def project_demand(df: pd.DataFrame, phi: float, mu: float):
165167
return df_demand[["region", "year", "demand_tot"]]
166168

167169

168-
def read_base_demand(filepath: str | Path):
170+
def read_base_demand(filepath: Union[str, Path]):
169171
with open(filepath, "r") as file:
170172
yaml_data = file.read()
171173

message_ix_models/model/transport/base.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Data preparation for the MESSAGEix-GLOBIOM base model."""
22

33
from functools import partial
4-
from itertools import pairwise, product
4+
from itertools import product
55
from pathlib import Path
66
from typing import TYPE_CHECKING, Any, Optional
77

@@ -66,7 +66,7 @@ def align_and_fill(
6666
)
6767

6868

69-
@minimum_version("pandas 2")
69+
@minimum_version("pandas 2; python 3.10")
7070
def smooth(c: Computer, key: "genno.Key", *, dim: str = "ya") -> "genno.Key":
7171
"""Implement ‘smoothing’ for `key` along the dimension `dim`.
7272
@@ -76,6 +76,8 @@ def smooth(c: Computer, key: "genno.Key", *, dim: str = "ya") -> "genno.Key":
7676
2. Remove those values.
7777
3. Fill by linear interpolation.
7878
"""
79+
from itertools import pairwise
80+
7981
assert key.tag != "2"
8082
ks = KeySeq(key.remove_tag(key.tag or ""))
8183

message_ix_models/model/transport/operator.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import re
55
from collections.abc import Mapping, Sequence
66
from functools import partial, reduce
7-
from itertools import pairwise, product
7+
from itertools import product
88
from operator import gt, le, lt
99
from typing import TYPE_CHECKING, Any, Hashable, Optional, Union, cast
1010

@@ -26,6 +26,7 @@
2626
from message_ix_models.util import (
2727
MappingAdapter,
2828
datetime_now_with_tz,
29+
minimum_version,
2930
nodes_ex_world,
3031
show_versions,
3132
)
@@ -953,6 +954,7 @@ def relabel2(qty: "AnyQuantity", new_dims: dict):
953954
return result
954955

955956

957+
@minimum_version("python 3.10")
956958
def uniform_in_dim(value: "AnyQuantity", dim: str = "y") -> "AnyQuantity":
957959
"""Construct a uniform distribution from `value` along its :math:`y`-dimension.
958960
@@ -967,6 +969,8 @@ def uniform_in_dim(value: "AnyQuantity", dim: str = "y") -> "AnyQuantity":
967969
and including :math:`d_{max}`. Values are the piecewise integral of the uniform
968970
distribution in the interval ending at the respective coordinate.
969971
"""
972+
from itertools import pairwise
973+
970974
d_max = value.coords[dim].item() # Upper end of the distribution
971975
width = 2 * value.item() # Width of a uniform distribution
972976
height = 1.0 / width # Height of the distribution

message_ix_models/testing/__init__.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import logging
22
import os
33
import shutil
4-
from base64 import b32hexencode
4+
5+
try:
6+
from base64 import b32hexencode as b32encode
7+
except ImportError:
8+
from base64 import b32encode
59
from collections.abc import Generator
610
from copy import deepcopy
711
from pathlib import Path
@@ -224,7 +228,7 @@ def bare_res(request, context: Context, solved: bool = False) -> message_ix.Scen
224228
new_name = request.node.name
225229
except AttributeError:
226230
# Generate a new scenario name with a random part
227-
new_name = f"baseline {b32hexencode(randbytes(3)).decode().rstrip('=').lower()}"
231+
new_name = f"baseline {b32encode(randbytes(3)).decode().rstrip('=').lower()}"
228232

229233
log.info(f"Clone to '{model_name}/{new_name}'")
230234
return base.clone(scenario=new_name, keep_solution=solved)
@@ -425,7 +429,7 @@ def loaded_snapshot(
425429
new_name = request.node.name
426430
except AttributeError:
427431
# Generate a new scenario name with a random part
428-
new_name = f"baseline {b32hexencode(randbytes(3)).decode().rstrip('=').lower()}"
432+
new_name = f"baseline {b32encode(randbytes(3)).decode().rstrip('=').lower()}"
429433

430434
log.info(f"Clone to '{model_name}/{new_name}'")
431435
yield base.clone(scenario=new_name, keep_solution=solved)

message_ix_models/tests/model/transport/test_config.py

+4-8
Original file line numberDiff line numberDiff line change
@@ -75,18 +75,14 @@ def test_navigate_scenario1(self, c, input, expected):
7575

7676
def test_scenario_conflict(self):
7777
# Giving both raises an exception
78-
with pytest.raises(
79-
ValueError,
80-
match=r"SCENARIO.A___ and T35_POLICY.ACT\|TEC are not compatible",
81-
):
78+
at = "(ACT|TEC)" # Order differs in Python 3.9
79+
expr = rf"SCENARIO.A___ and T35_POLICY.{at}\|{at} are not compatible"
80+
with pytest.raises(ValueError, match=expr):
8281
c = Config(futures_scenario="A---", navigate_scenario="act+tec")
8382

8483
# Also a conflict
8584
c = Config(navigate_scenario="act+tec")
86-
with pytest.raises(
87-
ValueError,
88-
match=r"SCENARIO.A___ and T35_POLICY.ACT\|TEC are not compatible",
89-
):
85+
with pytest.raises(ValueError, match=expr):
9086
c.set_futures_scenario("A---")
9187

9288

message_ix_models/tests/model/transport/test_operator.py

+2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
factor_ssp,
1818
sales_fraction_annual,
1919
transport_check,
20+
uniform_in_dim,
2021
)
2122
from message_ix_models.model.transport.structure import get_technology_groups
2223
from message_ix_models.project.navigate import T35_POLICY
@@ -139,6 +140,7 @@ def test_factor_ssp(test_context, ssp: SSP_2024) -> None:
139140
@pytest.mark.skipif(
140141
parse(version("genno")) < parse("1.25.0"), reason="Requires genno >= 1.25.0"
141142
)
143+
@uniform_in_dim.minimum_version
142144
def test_sales_fraction_annual():
143145
q = genno.Quantity(
144146
[[12.4, 6.1]], coords={"y": [2020], "n": list("AB")}, units="year"

message_ix_models/tests/report/test_compat.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import sys
23

34
from genno import Computer
45
from ixmp.testing import assert_logs
@@ -103,5 +104,11 @@ def test_prepare_techs_filter_error(caplog, monkeypatch):
103104
""":func:`.prepare_techs` logs warnings for invalid filters."""
104105
monkeypatch.setitem(TECH_FILTERS, "foo", "not a filter")
105106

106-
with assert_logs(caplog, "SyntaxError('invalid syntax", at_level=logging.WARNING):
107+
message = "SyntaxError('" + (
108+
"invalid syntax"
109+
if sys.version_info >= (3, 10)
110+
else "unexpected EOF while parsing"
111+
)
112+
113+
with assert_logs(caplog, message, at_level=logging.WARNING):
107114
prepare_techs(Computer(), get_codes("technology"))

message_ix_models/types.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,5 @@ class MaintainableArtefactArgs(TypedDict):
3737
is_external_reference: Optional[bool]
3838
is_final: Optional[bool]
3939
maintainer: Any
40-
version: Optional[Union[str, sdmx.model.common.Version]]
40+
# NB Only present from sdmx1 2.16; minimum for message-ix-models is sdmx1 2.13.1
41+
version: Optional[Union[str, "sdmx.model.common.Version"]]

message_ix_models/util/__init__.py

+11-6
Original file line numberDiff line numberDiff line change
@@ -573,16 +573,21 @@ def minimum_version(
573573
Parameters
574574
----------
575575
expr :
576-
Like "example 1.2.3.post0". The condition for the decorated function is that
577-
the installed version must be equal to or greater than this version.
576+
Like "pkgA 1.2.3.post0; pkgB 2025.2". The condition for the decorated function
577+
is that the installed version must be equal to or greater than this version.
578578
"""
579+
from platform import python_version
579580

580581
from packaging.version import parse
581582

582-
package, v_min = expr.split(" ")
583-
v_package = version(package)
584-
condition = parse(v_package) < parse(v_min)
585-
message = f" with {package} {v_package} < {v_min}"
583+
# Handle `expr`, updating `condition` and `message`
584+
condition, message = False, " with "
585+
for spec in expr.split(";"):
586+
package, v_min = spec.strip().split(" ")
587+
v_package = python_version() if package == "python" else version(package)
588+
if parse(v_package) < parse(v_min):
589+
condition = True
590+
message += f"{package} {v_package} < {v_min}"
586591

587592
# Create the decorator
588593
def decorator(func):

message_ix_models/util/_dataclasses.py

+10-3
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,28 @@
1313
import types
1414
from dataclasses import fields
1515

16+
try:
17+
from types import EllipsisType, NoneType, NotImplementedType
18+
except ImportError: # Python 3.9
19+
EllipsisType = type(...) # type: ignore [misc]
20+
NoneType = type(None) # type: ignore [misc]
21+
NotImplementedType = type(NotImplemented) # type: ignore [misc]
22+
1623
__all__ = [
1724
"asdict",
1825
]
1926

2027
_ATOMIC_TYPES = frozenset(
2128
{
22-
types.NoneType,
29+
NoneType,
2330
bool,
2431
int,
2532
float,
2633
str,
2734
complex,
2835
bytes,
29-
types.EllipsisType,
30-
types.NotImplementedType,
36+
EllipsisType,
37+
NotImplementedType,
3138
types.CodeType,
3239
types.BuiltinFunctionType,
3340
types.FunctionType,

message_ix_models/util/cache.py

+12-2
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
if TYPE_CHECKING:
3333
from pathlib import Path
3434

35+
3536
log = logging.getLogger(__name__)
3637

3738

@@ -46,11 +47,20 @@
4647
# Show genno how to hash function arguments seen in message_ix_models
4748

4849

49-
@genno.caching.Encoder.register
50-
def _quantity(o: AnyQuantity):
50+
def _quantity(o: "AnyQuantity"):
5151
return tuple(o.to_series().to_dict())
5252

5353

54+
try:
55+
genno.caching.Encoder.register(AnyQuantity)(_quantity)
56+
except TypeError: # Python 3.10 or earlier
57+
from genno.core.attrseries import AttrSeries
58+
from genno.core.sparsedataarray import SparseDataArray
59+
60+
genno.caching.Encoder.register(AttrSeries)(_quantity)
61+
genno.caching.Encoder.register(SparseDataArray)(_quantity)
62+
63+
5464
# Upstream
5565
@genno.caching.Encoder.register
5666
def _enum(o: Enum):

0 commit comments

Comments
 (0)