Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/pytest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@ jobs:
- ubuntu-latest
- windows-latest
python-version:
- "3.9" # Earliest supported version
- "3.10"
- "3.10" # Earliest supported version
- "3.11"
- "3.12"
- "3.13" # Latest supported version
- "3.13"
- "3.14" # Latest supported version
# commented: only enable once next Python version enters RC
# - "3.14.0-rc.1" # Development version
# - "3.15.0-rc.1" # Development version

fail-fast: false

Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.16.1
rev: v1.18.2
hooks:
- id: mypy
pass_filenames: false
Expand All @@ -16,7 +16,7 @@ repos:
- types-PyYAML
- types-requests
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.13
rev: v0.14.0
hooks:
- id: ruff-check
- id: ruff-format
Expand Down
8 changes: 6 additions & 2 deletions doc/whatsnew.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
What's new?
***********

.. Next release
.. ============
Next release
============

- Python 3.14 (`released 2025-10-07 <https://www.python.org/downloads/release/python-3140/>`_) is fully supported (:pull:`249`).
- Python 3.9 support is dropped, as `it has reached end-of-life <https://peps.python.org/pep-0569/#lifespan>`__ (:pull:`249`).
:mod:`sdmx` requires Python 3.10 or later.

v2.23.1 (2025-10-01)
====================
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ classifiers = [
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Scientific/Engineering",
"Topic :: Scientific/Engineering :: Information Analysis",
]
requires-python = ">=3.9"
requires-python = ">=3.10"
dependencies = [
"lxml >= 3.6",
"pandas >= 1.0",
Expand Down
10 changes: 5 additions & 5 deletions sdmx/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging
from functools import partial
from typing import IO, TYPE_CHECKING, Any, Optional, Union
from typing import IO, TYPE_CHECKING, Any
from warnings import warn

import requests
Expand Down Expand Up @@ -74,7 +74,7 @@ def __init__(
self,
source=None,
*,
session: Optional["requests.Session"] = None,
session: "requests.Session | None" = None,
log_level=None,
**session_opts,
):
Expand Down Expand Up @@ -333,9 +333,9 @@ def _collect(*keywords):

def get(
self,
resource_type: Union[str, Resource, None] = None,
resource_id: Optional[str] = None,
tofile: Union["os.PathLike", IO, None] = None,
resource_type: str | Resource | None = None,
resource_id: str | None = None,
tofile: "os.PathLike | IO | None" = None,
use_cache: bool = False,
dry_run: bool = False,
**kwargs,
Expand Down
8 changes: 4 additions & 4 deletions sdmx/compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from copy import copy
from dataclasses import dataclass, fields, is_dataclass
from functools import singledispatch
from typing import Any, TypeVar, Union
from typing import Any, TypeVar

import lxml.etree

Expand Down Expand Up @@ -157,7 +157,7 @@ def compare_dataclass(left, right, opts: Options, context: str) -> bool:
@compare.register(int)
@compare.register(str)
@compare.register(datetime.date)
def _eq(left: Union[int, str, datetime.date], right, opts, context=""):
def _eq(left: int | str | datetime.date, right, opts, context=""):
"""Built-in types that must compare equal."""
return left == right or (not opts.strict and right is None)

Expand All @@ -168,7 +168,7 @@ def _eq(left: Union[int, str, datetime.date], right, opts, context=""):
@compare.register(float)
@compare.register(type)
@compare.register(enum.Enum)
def _is(left: Union[None, bool, float, type, enum.Enum], right, opts, context):
def _is(left: None | bool | float | type | enum.Enum, right, opts, context):
"""Built-in types that must compare identical."""
return left is right or (not opts.strict and right is None or left is None)

Expand Down Expand Up @@ -203,7 +203,7 @@ def _(left: dict, right, opts, context=""):
# TODO When dropping support for Python <=3.10, change to '@compare.register'
@compare.register(list)
@compare.register(set)
def _(left: Union[list, set], right, opts, context=""):
def _(left: list | set, right, opts, context=""):
if len(left) != len(right):
opts.log(f"Mismatched length: {len(left)} != {len(right)}")
return False
Expand Down
73 changes: 35 additions & 38 deletions sdmx/convert/pandas.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from dataclasses import InitVar, dataclass, field
from itertools import chain, product, repeat
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, Optional, Union, cast
from typing import TYPE_CHECKING, Any, cast
from warnings import warn

import numpy as np
Expand Down Expand Up @@ -34,7 +34,7 @@
class ToDatetimeKeywords(TypedDict, total=False):
format: str

KeyOrAttributeValue = Union["common.KeyValue", "common.AttributeValue"]
KeyOrAttributeValue = common.KeyValue | common.AttributeValue


_HAS_PANDAS_2 = pd.__version__.split(".")[0] >= "2"
Expand Down Expand Up @@ -150,8 +150,8 @@ class ColumnSpec:

def __init__(
self,
pc: Optional["PandasConverter"] = None,
ds: Optional["common.BaseDataSet"] = None,
pc: "PandasConverter | None" = None,
ds: "common.BaseDataSet | None" = None,
) -> None:
if pc is None or ds is None:
return # Empty/placeholder
Expand Down Expand Up @@ -227,9 +227,9 @@ def __init__(

@staticmethod
def _maybe_construct_dsd(
dsd: Optional["common.BaseDataStructureDefinition"],
dsd: v21.DataStructureDefinition | v30.DataStructureDefinition | None,
obs: "common.BaseObservation",
) -> Union["v21.DataStructureDefinition", "v30.DataStructure"]:
) -> v21.DataStructureDefinition | v30.DataStructureDefinition:
"""If `dsd` is None, construct a DSD by inspection of `obs`."""
if dsd is not None:
return dsd
Expand Down Expand Up @@ -275,7 +275,7 @@ def convert_obs(self, obs: "common.BaseObservation") -> list:
key = obs.key
if self.constraint and key not in self.constraint:
# Emit an empty row to be dropped
result: Iterable[Union[str, None]] = repeat(None, len(self.obs))
result: Iterable[str | None] = repeat(None, len(self.obs))
else:
# Observation values
# FIXME Handled CodedObservationValue, similar to AttributeValue
Expand All @@ -288,7 +288,7 @@ def convert_obs(self, obs: "common.BaseObservation") -> list:
self.add_obs_attrib(avs)

# - Convert the observation Key using key Columns.
# - Convert the value to Optional[str].
# - Convert the value to str | None.
# - Convert the attribute values using attribute Columns.
result = chain(
[c(key.values) for c in self.key],
Expand All @@ -314,26 +314,26 @@ class PandasConverter(DispatchConverter):
attributes: Attributes = Attributes.none

#: If given, only Observations included by the *constraint* are returned.
constraint: Optional["ContentConstraint"] = None
constraint: "ContentConstraint | None" = None

#: Datatype for observation values. If :any:`None`, data values remain
#: :class:`object`/:class:`str`.
dtype: Union[type["np.generic"], type["ExtensionDtype"], str, None] = np.float64
dtype: type["np.generic"] | type["ExtensionDtype"] | str | None = np.float64

#: Axis on which to place a time dimension. One of:
#:
#: - :py:`-1`: disabled.
#: - :py:`0, "index"`: first/index axis.
#: - :py:`1, "columns"`: second/columns axis.
datetime_axis: Union[int, str] = -1
datetime_axis: int | str = -1

#: Dimension to convert to :class:`pandas.DatetimeIndex`. A :class:`str` value is
#: interpreted as a dimension ID.
datetime_dimension: Optional["common.DimensionComponent"] = None
datetime_dimension: "common.DimensionComponent | None" = None

#: Frequency for conversion to :class:`pandas.PeriodIndex`. A :class:`str` value is
#: interpreted as one of the :ref:`pd:timeseries.period_aliases`.
datetime_freq: Optional["DateOffset"] = None
datetime_freq: "DateOffset | None" = None

#: include : iterable of str or str, optional
#: One or more of the attributes of the StructureMessage ('category_scheme',
Expand Down Expand Up @@ -367,9 +367,7 @@ class PandasConverter(DispatchConverter):
# Columns to be set as index levels, then unstacked.
_unstack: list[str] = field(default_factory=list)

_context: dict[Union[str, type], Any] = field(
default_factory=lambda: dict(compat=False)
)
_context: dict[str | type, Any] = field(default_factory=lambda: dict(compat=False))

def get_components(self, kind) -> list["common.Component"]:
"""Return an appropriate list of dimensions or attributes."""
Expand Down Expand Up @@ -434,21 +432,22 @@ def handle_datetime(self, value: Any) -> None:
stacklevel=2,
)

if isinstance(value, (str, common.DimensionComponent)):
self.datetime_dimension = value # type: ignore [assignment]
elif isinstance(value, dict):
# Unpack a dict of 'advanced' arguments
self.datetime_axis = value.pop("axis", self.datetime_axis)
self.datetime_dimension = value.pop("dim", self.datetime_dimension)
self.datetime_freq = value.pop("freq", self.datetime_freq)
if len(value):
raise ValueError(f"Unexpected datetime={tuple(sorted(value))!r}")
elif isinstance(value, bool):
self.datetime_axis = 0 if value else -1
else:
raise TypeError(f"PandasConverter(…, datetime={type(value)})")

def __post_init__(self, datetime: Any, rtype: Optional[str]) -> None:
match value:
case str() | common.DimensionComponent():
self.datetime_dimension = value # type: ignore [assignment]
case dict():
# Unpack a dict of 'advanced' arguments
self.datetime_axis = value.pop("axis", self.datetime_axis)
self.datetime_dimension = value.pop("dim", self.datetime_dimension)
self.datetime_freq = value.pop("freq", self.datetime_freq)
if len(value):
raise ValueError(f"Unexpected datetime={tuple(sorted(value))!r}")
case bool():
self.datetime_axis = 0 if value else -1
case _:
raise TypeError(f"PandasConverter(…, datetime={type(value)})")

def __post_init__(self, datetime: Any, rtype: str | None) -> None:
"""Transform and validate arguments."""
# Raise on unsupported arguments
if isinstance(
Expand Down Expand Up @@ -626,7 +625,7 @@ def convert_structuremessage(c: "PandasConverter", obj: message.StructureMessage
Keys are StructureMessage attributes; values are pandas objects.
"""
attrs = sorted(c.include)
result: DictLike[str, Union[pd.Series, pd.DataFrame]] = DictLike()
result: DictLike[str, pd.Series | pd.DataFrame] = DictLike()
for a in attrs:
dl = c.convert(getattr(obj, a))
if len(dl):
Expand Down Expand Up @@ -755,17 +754,15 @@ def _convert_datetime(df: "pd.DataFrame", c: "PandasConverter") -> "pd.DataFrame
return df.assign(**{dim.id: pd.to_datetime(df[dim.id], **dt_kw)})


def _ensure_multiindex(obj: Union[pd.Series, pd.DataFrame]):
def _ensure_multiindex(obj: pd.Series | pd.DataFrame):
if not isinstance(obj.index, pd.MultiIndex):
obj.index = pd.MultiIndex.from_product(
[obj.index.to_list()], names=[obj.index.name]
)
return obj


def _reshape(
df: "pd.DataFrame", c: "PandasConverter"
) -> Union[pd.Series, pd.DataFrame]:
def _reshape(df: "pd.DataFrame", c: "PandasConverter") -> pd.Series | pd.DataFrame:
"""Reshape `df` to provide expected return types."""

if c._strict:
Expand All @@ -790,7 +787,7 @@ def _reshape(
return result


def _to_periodindex(obj: Union["pd.Series", "pd.DataFrame"], c: "PandasConverter"):
def _to_periodindex(obj: "pd.Series | pd.DataFrame", c: "PandasConverter"):
"""Convert a 1-D datetime index on `obj` to a PeriodIndex."""
result = obj

Expand Down Expand Up @@ -887,7 +884,7 @@ def add_item(item):
add_item(item)

# Convert to DataFrame
result: Union[pd.DataFrame, pd.Series] = pd.DataFrame.from_dict(
result: pd.DataFrame | pd.Series = pd.DataFrame.from_dict(
items,
orient="index",
dtype=object, # type: ignore [arg-type]
Expand Down
8 changes: 4 additions & 4 deletions sdmx/dictlike.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging
import typing
from collections.abc import MutableMapping
from dataclasses import fields
from typing import Generic, TypeVar, Union, get_args, get_origin
from typing import Generic, TypeVar, get_args, get_origin

from sdmx.compare import Comparable

Expand All @@ -11,7 +11,7 @@
VT = TypeVar("VT")


class DictLike(dict, typing.MutableMapping[KT, VT], Comparable):
class DictLike(dict, MutableMapping[KT, VT], Comparable):
"""Container with features of :class:`dict`, attribute access, and validation."""

__slots__ = ("__dict__", "_types")
Expand All @@ -32,7 +32,7 @@ def with_types(cls, key_type, value_type):
result._types = (key_type, value_type)
return result

def __getitem__(self, key: Union[KT, int]) -> VT:
def __getitem__(self, key: KT | int) -> VT:
""":meth:`dict.__getitem__` with integer access."""
try:
return super().__getitem__(key)
Expand Down
8 changes: 4 additions & 4 deletions sdmx/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"""

from typing import Optional, Text
from typing import Text

import pandas as pd

Expand All @@ -31,10 +31,10 @@

class DataSet(AnnotableArtefact):
# SDMX-IM features
action: Optional[ActionType] = None
action: ActionType | None = None
attrib: DictLike[str, AttributeValue] = DictLike()
valid_from: Optional[Text] = None
structured_by: Optional[DataStructureDefinition] = None
valid_from: Text | None = None
structured_by: DataStructureDefinition | None = None

# Internal storage: a pd.DataFrame with columns:
# - 'value': the Observation value.
Expand Down
6 changes: 3 additions & 3 deletions sdmx/format/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from dataclasses import InitVar, dataclass, field
from enum import Enum, IntFlag
from functools import lru_cache
from typing import Literal, Optional, Union
from typing import Literal

from sdmx.util import parse_content_type

Expand Down Expand Up @@ -48,12 +48,12 @@ class MediaType:

#: Format version.
version: Version = field(init=False)
_version: InitVar[Union[str, Version]]
_version: InitVar[str | Version]

flags: Flag = Flag(0)

#: Specify the full media type string.
full: Optional[str] = None
full: str | None = None

def __post_init__(self, _version):
self.__dict__["version"] = Version[_version]
Expand Down
Loading