Skip to content

Commit f97e386

Browse files
authored
Merge pull request #985 from iiasa/python-3.14
Confirm support for Python 3.14 and drop 3.9
2 parents fa45877 + 5bd58c3 commit f97e386

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+234
-230
lines changed

.github/workflows/nightly.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ env:
2424
# Oldest version that can reliably be downloaded
2525
gams-version: 48.6.1
2626
os: ubuntu-latest
27-
python-version: "3.13"
27+
python-version: "3.14"
2828

2929
permissions: {contents: read}
3030

.github/workflows/pytest.yaml

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ env:
2424
GAMS_VERSION: 48.6.1 # Oldest version of GAMS that can reliably be downloaded
2525
depth: 100 # Must be large enough to include the most recent release
2626
label: "safe to test" # Label that must be applied to run on PRs from forks
27-
python-version: "3.13" # For non-matrixed jobs
27+
python-version: "3.14" # For non-matrixed jobs
2828
# Install:
2929
# - dask: to work around https://github.com/khaeru/genno/issues/171
3030
# - ixmp: from its `main` branch.
@@ -57,34 +57,23 @@ jobs:
5757
strategy:
5858
matrix:
5959
os:
60-
- macos-13
6160
- macos-latest
6261
- ubuntu-latest
6362
- windows-latest
6463
python-version:
65-
- "3.9" # Earliest version supported by message_ix
66-
- "3.10"
64+
- "3.10" # Earliest version supported by message_ix
6765
- "3.11"
6866
- "3.12"
69-
- "3.13" # Latest version supported by message_ix
67+
- "3.13"
68+
- "3.14" # Latest version supported by message_ix
7069

7170
# Below this comment are newly released or development versions of
7271
# Python. For these versions, binary wheels are not available for some
7372
# dependencies, e.g. llvmlite, numba, numpy, and/or pandas. Compiling
7473
# these on the job runner requires a more elaborate build environment,
7574
# currently out of scope for the message_ix project.
7675

77-
# - "3.14.0-alpha.1" # Development version
78-
79-
exclude:
80-
# Specific version combinations that are invalid / not to be used
81-
# No arm64 distributions of JPype for these Pythons
82-
- { os: macos-latest, python-version: "3.9" }
83-
# Redundant with macos-latest
84-
- { os: macos-13, python-version: "3.10" }
85-
- { os: macos-13, python-version: "3.11" }
86-
- { os: macos-13, python-version: "3.12" }
87-
- { os: macos-13, python-version: "3.13" }
76+
# - "3.15.0-alpha.1" # Development version
8877

8978
fail-fast: false
9079

RELEASE_NOTES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ Adjust any imports like the following:
2222
All changes
2323
-----------
2424

25+
- :mod:`message_ix` is tested and compatible with `Python 3.14 <https://www.python.org/downloads/release/python-3140/>`__ (:pull:`985`).
26+
- Support for Python 3.9 is dropped (:pull:`985`), as it has reached end-of-life.
2527
- Add representation of commodity flows associated with construction and retirement of technology capacity (:pull:`451`).
2628

2729
- New parameters
@@ -48,6 +50,7 @@ All changes
4850
- Document the :ref:`minimum version of Java <install-java>` required for :class:`ixmp.JDBCBackend <ixmp.backend.jdbc.JDBCBackend>` (:pull:`962`).
4951
- Improve type hinting (:pull:`963`).
5052
- Fix capitalization in auxiliary_settings.gms to enable GDX output file compression on MacOS and Linux. (:pull:`965`)
53+
5154
All changes
5255
-----------
5356

doc/conf.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import os
88
from importlib.metadata import version as get_version
99
from pathlib import Path
10-
from typing import Optional
1110

1211
# -- Project information ---------------------------------------------------------------
1312

@@ -193,7 +192,7 @@
193192
# -- Options for sphinx.ext.intersphinx ------------------------------------------------
194193

195194

196-
def local_inv(name: str, *parts: str) -> Optional[str]:
195+
def local_inv(name: str, *parts: str) -> str | None:
197196
"""Construct the path to a local intersphinx inventory."""
198197

199198
from importlib.util import find_spec

message_ix/common.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from copy import copy
77
from dataclasses import InitVar, dataclass, field
88
from pathlib import Path
9-
from typing import TYPE_CHECKING, Any, Optional
9+
from typing import TYPE_CHECKING, Any
1010

1111
import ixmp.model.gams
1212
from ixmp import config
@@ -96,7 +96,7 @@ class Item:
9696
dims: tuple[str, ...] = field(default_factory=tuple)
9797

9898
#: Text description of the item.
99-
description: Optional[str] = None
99+
description: str | None = None
100100

101101
def __post_init__(self, expr):
102102
if expr == "":
@@ -169,12 +169,12 @@ class GAMSModel(ixmp.model.gams.GAMSModel):
169169
model_dir: Path
170170

171171
#: Optional minimum version of GAMS.
172-
GAMS_min_version: Optional[str] = None
172+
GAMS_min_version: str | None = None
173173

174174
#: Keyword arguments to map to GAMS `solve_args`.
175175
keyword_to_solve_arg: list[tuple[str, type, str]]
176176

177-
def __init__(self, name: Optional[str] = None, **model_options) -> None:
177+
def __init__(self, name: str | None = None, **model_options) -> None:
178178
if gmv := self.GAMS_min_version:
179179
# Check the minimum GAMS version.
180180
version = ixmp.model.gams.gams_version() or ""

message_ix/core.py

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from collections.abc import Iterable, Mapping, Sequence
44
from functools import lru_cache
55
from itertools import chain, product, zip_longest
6-
from typing import Optional, TypeVar, Union
6+
from typing import TypeVar
77
from warnings import warn
88

99
import ixmp
@@ -255,12 +255,15 @@ def cat(self, name, cat):
255255
def add_par(
256256
self,
257257
name: str,
258-
key_or_data: Optional[
259-
Union[int, str, Sequence[Union[int, str]], dict, pd.DataFrame]
260-
] = None,
261-
value: Union[float, Iterable[float], None] = None,
262-
unit: Union[str, Iterable[str], None] = None,
263-
comment: Union[str, Iterable[str], None] = None,
258+
key_or_data: int
259+
| str
260+
| Sequence[int | str]
261+
| dict
262+
| pd.DataFrame
263+
| None = None,
264+
value: float | Iterable[float] | None = None,
265+
unit: str | Iterable[str] | None = None,
266+
comment: str | Iterable[str] | None = None,
264267
) -> None:
265268
# ixmp.Scenario.add_par() is typed as accepting only str, but this method also
266269
# accepts int for "year"-like dimensions. Proxy the call to avoid type check
@@ -295,14 +298,12 @@ def add_par(
295298
def add_set(
296299
self,
297300
name: str,
298-
key: Union[
299-
int,
300-
str,
301-
Iterable[object],
302-
dict[str, Union[Sequence[int], Sequence[str]]],
303-
pd.DataFrame,
304-
],
305-
comment: Union[str, Sequence[str], None] = None,
301+
key: int
302+
| str
303+
| Iterable[object]
304+
| dict[str, Sequence[int] | Sequence[str]]
305+
| pd.DataFrame,
306+
comment: str | Sequence[str] | None = None,
306307
) -> None:
307308
# ixmp.Scenario.add_par() is typed as accepting only str, but this method also
308309
# accepts int for "year"-like dimensions. Proxy the call to avoid type check
@@ -373,8 +374,8 @@ def recurse(k, v, parent="World"):
373374
def add_horizon(
374375
self,
375376
year: Iterable[int] = [],
376-
firstmodelyear: Optional[int] = None,
377-
data: Optional[dict] = None,
377+
firstmodelyear: int | None = None,
378+
data: dict | None = None,
378379
) -> None:
379380
"""Set the scenario time horizon via ``year`` and related categories.
380381
@@ -502,7 +503,7 @@ def add_horizon(
502503

503504
def vintage_and_active_years(
504505
self,
505-
ya_args: Union[tuple[str, str], tuple[str, str, Union[int, str]], None] = None,
506+
ya_args: tuple[str, str] | tuple[str, str, int | str] | None = None,
506507
tl_only: bool = True,
507508
**kwargs,
508509
) -> pd.DataFrame:
@@ -653,7 +654,7 @@ def _valid(elem):
653654
#: Alias for :meth:`vintage_and_active_years`.
654655
yv_ya = vintage_and_active_years
655656

656-
def years_active(self, node: str, tec: str, yr_vtg: Union[int, str]) -> list[int]:
657+
def years_active(self, node: str, tec: str, yr_vtg: int | str) -> list[int]:
657658
"""Return periods in which `tec` hnology of `yr_vtg` can be active in `node`.
658659
659660
The :ref:`parameters <params-tech>` ``duration_period`` and
@@ -762,7 +763,7 @@ def solve(self, model="MESSAGE", solve_options={}, **kwargs):
762763

763764
def add_macro(
764765
self,
765-
data: Union[Mapping, os.PathLike],
766+
data: Mapping | os.PathLike,
766767
scenario=None,
767768
check_convergence=True,
768769
**kwargs,

message_ix/macro/calibrate.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from functools import partial
66
from operator import itemgetter, mul
77
from pathlib import Path
8-
from typing import TYPE_CHECKING, Optional, Union
8+
from typing import TYPE_CHECKING
99

1010
import numpy as np
1111
import pandas as pd
@@ -460,7 +460,7 @@ def validate_transform(
460460
return df.set_index(idx)["value"]
461461

462462

463-
def _validate_data(name: Optional[str], df: "DataFrame", s: Structures) -> list:
463+
def _validate_data(name: str | None, df: "DataFrame", s: Structures) -> list:
464464
"""Validate input `df` against `s` for MACRO parameter `name` calibration .
465465
466466
Parameters
@@ -553,7 +553,7 @@ def ym1(df: "Series", macro_periods: Collection[int]) -> int:
553553

554554

555555
def add_model_data(
556-
base: "Scenario", clone: "Scenario", data: Union[Mapping, os.PathLike]
556+
base: "Scenario", clone: "Scenario", data: Mapping | os.PathLike
557557
) -> None:
558558
"""Calculate and add MACRO structure and data to `clone`.
559559
@@ -643,8 +643,8 @@ def calibrate(s, check_convergence: bool = True, **kwargs):
643643

644644
def prepare_computer(
645645
base: "Scenario",
646-
target: Optional["Scenario"] = None,
647-
data: Union[Mapping, os.PathLike, None] = None,
646+
target: "Scenario | None" = None,
647+
data: Mapping | os.PathLike | None = None,
648648
) -> "genno.Computer":
649649
"""Prepare a :class:`.Reporter` to perform MACRO calibration calculations.
650650

message_ix/report/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from collections.abc import Mapping
33
from functools import lru_cache, partial
44
from operator import itemgetter
5-
from typing import TYPE_CHECKING, Union, cast
5+
from typing import TYPE_CHECKING, cast
66

77
from genno.operator import broadcast_map
88
from ixmp.report import (
@@ -220,7 +220,7 @@ def from_scenario(cls, scenario, **kwargs) -> "Reporter":
220220
f'Scenario "{scenario.model}/{scenario.scenario}" has no solution'
221221
)
222222
log.warning("Some reporting may not function as expected")
223-
fail_action: Union[int, str] = logging.DEBUG
223+
fail_action: int | str = logging.DEBUG
224224
else:
225225
fail_action = "raise"
226226

@@ -288,7 +288,7 @@ def add_sankey(
288288
# Generate the plotly.Figure object; return the key
289289
return str(self.add(f"sankey figure {unique}", sankey, k[1], k[2]))
290290

291-
def add_tasks(self, fail_action: Union[int, str] = "raise") -> None:
291+
def add_tasks(self, fail_action: int | str = "raise") -> None:
292292
"""Add the pre-defined MESSAGEix reporting tasks to the Reporter.
293293
294294
Parameters

message_ix/report/pyam.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import TYPE_CHECKING, Optional, TypedDict
1+
from typing import TYPE_CHECKING, TypedDict
22

33
if TYPE_CHECKING:
44
import pandas
@@ -8,15 +8,15 @@ class CollapseMessageColsKw(TypedDict, total=False):
88
"""Type hint for :class:`dict` of keyword args to :func:`collapse_message_cols`."""
99

1010
df: "pandas.DataFrame"
11-
var: Optional[str]
12-
kind: Optional[str]
11+
var: str | None
12+
kind: str | None
1313
var_cols: list[str]
1414

1515

1616
def collapse_message_cols(
1717
df: "pandas.DataFrame",
18-
var: Optional[str] = None,
19-
kind: Optional[str] = None,
18+
var: str | None = None,
19+
kind: str | None = None,
2020
var_cols=[],
2121
) -> "pandas.DataFrame":
2222
""":mod:`genno.compat.pyam` `collapse=...` callback for MESSAGEix quantities.

message_ix/testing/__init__.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import io
22
import os
3+
import platform
34
from collections.abc import Callable, Generator
45
from itertools import product
56
from pathlib import Path
6-
from typing import TYPE_CHECKING, Any, Optional, Union
7+
from typing import TYPE_CHECKING, Any
78

89
import ixmp
910
import numpy as np
@@ -28,12 +29,21 @@
2829
def pytest_configure(config: pytest.Config) -> None:
2930
"""Force iam-units to use a distinct cache for each worker.
3031
31-
Work around for https://github.com/hgrecco/flexcache/issues/6 /
32-
https://github.com/IAMconsortium/units/issues/54.
32+
Work arounds for:
33+
34+
1. https://github.com/hgrecco/flexcache/issues/6 /
35+
https://github.com/IAMconsortium/units/issues/54.
36+
2. https://github.com/python/cpython/issues/125235,
37+
https://github.com/astral-sh/uv/issues/7036, or similar.
3338
"""
3439
name = f"iam-units-{os.environ.get('PYTEST_XDIST_WORKER', '')}".rstrip("-")
3540
os.environ["IAM_UNITS_CACHE"] = str(config.cache.mkdir(name))
3641

42+
if GHA and platform.system() == "Windows":
43+
import matplotlib
44+
45+
matplotlib.use("agg")
46+
3747

3848
def pytest_report_header(config: pytest.Config, start_path: Path) -> str:
3949
"""Add the message_ix import path to the pytest report header."""
@@ -90,7 +100,7 @@ def pytest_sessionstart() -> None:
90100

91101
# Create and populate ixmp databases
92102

93-
_ms: list[Union[str, float]] = [
103+
_ms: list[str | float] = [
94104
SCENARIO["dantzig"]["model"],
95105
SCENARIO["dantzig"]["scenario"],
96106
]
@@ -165,7 +175,7 @@ def make_austria( # noqa: C901
165175
solve: bool = False,
166176
quiet: bool = True,
167177
*,
168-
request: Optional["pytest.FixtureRequest"] = None,
178+
request: "pytest.FixtureRequest | None" = None,
169179
) -> Scenario:
170180
"""Return an :class:`message_ix.Scenario` for the Austrian energy system.
171181
@@ -352,7 +362,7 @@ def make_dantzig(
352362
solve: bool = False,
353363
multi_year: bool = False,
354364
*,
355-
request: Optional["pytest.FixtureRequest"] = None,
365+
request: "pytest.FixtureRequest | None" = None,
356366
**solve_opts,
357367
) -> Scenario:
358368
"""Return an :class:`message_ix.Scenario` for Dantzig's canning problem.
@@ -521,7 +531,7 @@ def make_westeros(
521531
quiet: bool = True,
522532
model_horizon: list[int] = [700, 710, 720],
523533
*,
524-
request: Optional["pytest.FixtureRequest"] = None,
534+
request: "pytest.FixtureRequest | None" = None,
525535
) -> Scenario:
526536
"""Return a new :class:`message_ix.Scenario` containing the ‘Westeros’ model.
527537
@@ -715,7 +725,7 @@ def make_subannual(
715725
var_cost={},
716726
operation_factor={},
717727
*,
718-
request: Optional["pytest.FixtureRequest"] = None,
728+
request: "pytest.FixtureRequest | None" = None,
719729
) -> Scenario:
720730
"""Return an :class:`message_ix.Scenario` with subannual time resolution.
721731

0 commit comments

Comments
 (0)