Skip to content
Open
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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ repos:
hooks:
- id: isort
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.3
rev: v0.12.10
hooks:
- id: ruff-check
args: [
Expand Down
37 changes: 37 additions & 0 deletions docs/philosophy.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,43 @@ The type `TimestampSeries` is the result of creating a series from `pd.to_dateti
the type `TimedeltaSeries` is the result of subtracting two `TimestampSeries` as well as
the result of `pd.to_timedelta()`.

### Generic Series have restricted arithmetic

Consider the following Series from a DataFrame:

```python
import pandas as pd
from typing_extensions import reveal_type
from typing import TYPE_CHECKING, cast

if TYPE_CHECKING:
from pandas.core.series import TimestampSeries # noqa: F401


frame = pd.DataFrame({"timestamp": [pd.Timestamp(2025, 8, 26)], "tag": ["one"], "value": [1.0]})
values = frame["value"]
reveal_type(values) # type checker: Series[Any], runtime: Series
new_values = values + 2

timestamps = frame["timestamp"]
reveal_type(timestamps) # type checker: Series[Any], runtime: Series
reveal_type(timestamps - pd.Timestamp(2025, 7, 12)) # type checker: Unknown and error, runtime: Series
reveal_type(cast("TimestampSeries", timestamps) - pd.Timestamp(2025, 7, 12)) # type checker: TimedeltaSeries, runtime: Series

tags = frame["tag"]
reveal_type("suffix" + tags) # type checker: Never, runtime: Series
```

Since they are taken from a DataFrame, all three of them, `values`, `timestamps`
and `tags`, are recognized by type checkers as `Series[Any]`. The code snippet
runs fine at runtime. In the stub for type checking, however, we restrict
generic Series to perform arithmetic operations only with numeric types, and
give `Series[Any]` for the results. For `Timedelta`, `Timestamp`, `str`, etc.,
arithmetic is restricted to `Series[Any]` and the result is either undefined,
showing `Unknown` and errors, or `Never`. Users are encouraged to cast such
generic Series to ones with concrete types, so that type checkers can provide
meaningful results.

### Interval is Generic

A pandas `Interval` can be a time interval, an interval of integers, or an interval of
Expand Down
98 changes: 69 additions & 29 deletions pandas-stubs/core/series.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,6 @@ from pandas._typing import (
np_ndarray_anyint,
np_ndarray_bool,
np_ndarray_complex,
np_ndarray_dt,
np_ndarray_float,
np_ndarray_str,
np_ndarray_td,
Expand Down Expand Up @@ -261,9 +260,20 @@ class _LocIndexerSeries(_LocIndexer, Generic[S1]):
value: S1 | ArrayLike | Series[S1] | None,
) -> None: ...

_ListLike: TypeAlias = (
_ListLike: TypeAlias = ArrayLike | dict[_str, np.ndarray] | SequenceNotStr[S1]
_ListLikeS1: TypeAlias = (
ArrayLike | dict[_str, np.ndarray] | Sequence[S1] | IndexOpsMixin[S1]
)
_NumListLike: TypeAlias = (
ExtensionArray
| np_ndarray_bool
| np_ndarray_anyint
| np_ndarray_float
| np_ndarray_complex
| dict[_str, np.ndarray]
| Sequence[complex]
| IndexOpsMixin[complex]
)

class Series(IndexOpsMixin[S1], NDFrame):
# Define __index__ because mypy thinks Series follows protocol `SupportsIndex` https://github.com/pandas-dev/pandas-stubs/pull/1332#discussion_r2285648790
Expand Down Expand Up @@ -419,7 +429,9 @@ class Series(IndexOpsMixin[S1], NDFrame):
@overload
def __new__(
cls,
data: S1 | _ListLike[S1] | dict[HashableT1, S1] | KeysView[S1] | ValuesView[S1],
data: (
S1 | _ListLikeS1[S1] | dict[HashableT1, S1] | KeysView[S1] | ValuesView[S1]
),
index: AxesData | None = ...,
dtype: Dtype = ...,
name: Hashable = ...,
Expand Down Expand Up @@ -1619,7 +1631,9 @@ class Series(IndexOpsMixin[S1], NDFrame):
# just failed to generate these so I couldn't match
# them up.
@overload
def __add__(self: Series[Never], other: Scalar | _ListLike | Series) -> Series: ...
def __add__(self: Series[Never], other: _str) -> Never: ...
@overload
def __add__(self: Series[Never], other: complex | _ListLike | Series) -> Series: ...
@overload
def __add__(self, other: Series[Never]) -> Series: ...
@overload
Expand Down Expand Up @@ -1697,7 +1711,15 @@ class Series(IndexOpsMixin[S1], NDFrame):
@overload
def add(
self: Series[Never],
other: Scalar | _ListLike | Series,
other: _str,
level: Level | None = None,
fill_value: float | None = None,
axis: int = 0,
) -> Never: ...
@overload
def add(
self: Series[Never],
other: complex | _ListLike | Series,
level: Level | None = None,
fill_value: float | None = None,
axis: int = 0,
Expand Down Expand Up @@ -1840,7 +1862,11 @@ class Series(IndexOpsMixin[S1], NDFrame):
axis: int = 0,
) -> Series[_str]: ...
@overload # type: ignore[override]
def __radd__(self: Series[Never], other: Scalar | _ListLike) -> Series: ...
def __radd__(self: Series[Never], other: _str) -> Never: ...
@overload
def __radd__(
self: Series[Never], other: complex | _ListLike | Series
) -> Series: ...
@overload
def __radd__(
self: Series[bool],
Expand Down Expand Up @@ -1912,7 +1938,23 @@ class Series(IndexOpsMixin[S1], NDFrame):
@overload
def radd(
self: Series[Never],
other: Scalar | _ListLike | Series,
other: _str,
level: Level | None = None,
fill_value: float | None = None,
axis: int = 0,
) -> Never: ...
@overload
def radd(
self: Series[Never],
other: complex | _ListLike | Series,
level: Level | None = None,
fill_value: float | None = None,
axis: int = 0,
) -> Series: ...
@overload
def radd(
self: Series[S1],
other: Series[Never],
level: Level | None = None,
fill_value: float | None = None,
axis: int = 0,
Expand Down Expand Up @@ -2051,7 +2093,9 @@ class Series(IndexOpsMixin[S1], NDFrame):
self, other: S1 | _ListLike | Series[S1] | datetime | timedelta | date
) -> Series[_bool]: ...
@overload
def __mul__(self: Series[Never], other: complex | _ListLike | Series) -> Series: ...
def __mul__(
self: Series[Never], other: complex | _NumListLike | Series
) -> Series: ...
@overload
def __mul__(self, other: Series[Never]) -> Series: ... # type: ignore[overload-overlap]
@overload
Expand Down Expand Up @@ -2246,7 +2290,7 @@ class Series(IndexOpsMixin[S1], NDFrame):
) -> TimedeltaSeries: ...
@overload
def __rmul__(
self: Series[Never], other: complex | _ListLike | Series
self: Series[Never], other: complex | _NumListLike | Series
) -> Series: ...
@overload
def __rmul__(self, other: Series[Never]) -> Series: ... # type: ignore[overload-overlap]
Expand Down Expand Up @@ -2475,12 +2519,11 @@ class Series(IndexOpsMixin[S1], NDFrame):
@overload
def __rxor__(self, other: int | np_ndarray_anyint | Series[int]) -> Series[int]: ...
@overload
def __sub__(
self: Series[Never],
other: datetime | np.datetime64 | np_ndarray_dt | TimestampSeries,
) -> TimedeltaSeries: ...
def __sub__(self: Series[Never], other: TimestampSeries) -> Never: ...
@overload
def __sub__(self: Series[Never], other: complex | _ListLike | Series) -> Series: ...
def __sub__(
self: Series[Never], other: complex | _NumListLike | Series
) -> Series: ...
@overload
def __sub__(self, other: Series[Never]) -> Series: ... # type: ignore[overload-overlap]
@overload
Expand Down Expand Up @@ -2571,15 +2614,15 @@ class Series(IndexOpsMixin[S1], NDFrame):
@overload
def sub(
self: Series[Never],
other: datetime | np.datetime64 | np_ndarray_dt | TimestampSeries,
other: TimestampSeries,
level: Level | None = None,
fill_value: float | None = None,
axis: int = 0,
) -> TimedeltaSeries: ...
) -> Never: ...
@overload
def sub(
self: Series[Never],
other: complex | _ListLike | Series,
other: complex | _NumListLike | Series,
level: Level | None = None,
fill_value: float | None = None,
axis: int = 0,
Expand Down Expand Up @@ -2705,13 +2748,10 @@ class Series(IndexOpsMixin[S1], NDFrame):
axis: int = 0,
) -> TimedeltaSeries: ...
@overload
def __rsub__( # type: ignore[misc]
self: Series[Never],
other: datetime | np.datetime64 | np_ndarray_dt | TimestampSeries,
) -> TimedeltaSeries: ...
def __rsub__(self: Series[Never], other: TimestampSeries) -> Never: ... # type: ignore[misc]
@overload
def __rsub__(
self: Series[Never], other: complex | _ListLike | Series
self: Series[Never], other: complex | _NumListLike | Series
) -> Series: ...
@overload
def __rsub__(self, other: Series[Never]) -> Series: ...
Expand Down Expand Up @@ -2781,15 +2821,15 @@ class Series(IndexOpsMixin[S1], NDFrame):
@overload
def rsub(
self: Series[Never],
other: datetime | np.datetime64 | np_ndarray_dt | TimestampSeries,
other: TimestampSeries,
level: Level | None = None,
fill_value: float | None = None,
axis: int = 0,
) -> TimedeltaSeries: ...
) -> Never: ...
@overload
def rsub(
self: Series[Never],
other: complex | _ListLike | Series,
other: complex | _NumListLike | Series,
level: Level | None = None,
fill_value: float | None = None,
axis: int = 0,
Expand Down Expand Up @@ -2887,8 +2927,8 @@ class Series(IndexOpsMixin[S1], NDFrame):
axis: int = 0,
) -> Series[complex]: ...
@overload
def __truediv__(
self: Series[Never], other: complex | _ListLike | Series
def __truediv__( # type:ignore[overload-overlap]
self: Series[Never], other: complex | _NumListLike | Series
) -> Series: ...
@overload
def __truediv__(self, other: Series[Never]) -> Series: ...
Expand Down Expand Up @@ -3083,8 +3123,8 @@ class Series(IndexOpsMixin[S1], NDFrame):
) -> Series: ...
div = truediv
@overload
def __rtruediv__(
self: Series[Never], other: complex | _ListLike | Series
def __rtruediv__( # type:ignore[overload-overlap]
self: Series[Never], other: complex | _NumListLike | Series
) -> Series: ...
@overload
def __rtruediv__(self, other: Series[Never]) -> Series: ...
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ types-pytz = ">= 2022.1.1"
numpy = ">= 1.23.5"

[tool.poetry.group.dev.dependencies]
mypy = "1.17.0"
mypy = "1.17.1"
pandas = "2.3.1"
pyarrow = ">=10.0.1"
pytest = ">=7.1.2"
pyright = ">=1.1.404"
ty = "^0.0.1a8"
ty = "^0.0.1a9"
pyrefly = "^0.21.0"
poethepoet = ">=0.16.5"
loguru = ">=0.6.0"
Expand Down
16 changes: 8 additions & 8 deletions tests/series/arithmetic/str/test_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@


def test_add_py_scalar() -> None:
"""Testpd.Series[str]+ Python native 'scalar's"""
"""Test pd.Series[str] + Python native 'scalar's"""
i = 4
r0 = "right"

Expand All @@ -35,12 +35,12 @@ def test_add_py_scalar() -> None:
check(assert_type(left.add(r0), "pd.Series[str]"), pd.Series, str)

if TYPE_CHECKING_INVALID_USAGE:
left.radd(i) # type: ignore[call-overload] # pyright: ignore[reportArgumentType]
left.radd(i) # type: ignore[call-overload] # pyright: ignore[reportArgumentType, reportCallIssue]
check(assert_type(left.radd(r0), "pd.Series[str]"), pd.Series, str)


def test_add_py_sequence() -> None:
"""Testpd.Series[str]+ Python native sequence"""
"""Test pd.Series[str] + Python native sequence"""
i = [3, 5, 8]
r0 = ["a", "bc", "def"]
r1 = tuple(r0)
Expand All @@ -61,13 +61,13 @@ def test_add_py_sequence() -> None:
check(assert_type(left.add(r1), "pd.Series[str]"), pd.Series, str)

if TYPE_CHECKING_INVALID_USAGE:
left.radd(i) # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
left.radd(i) # type: ignore[arg-type] # pyright: ignore[reportArgumentType,reportCallIssue]
check(assert_type(left.radd(r0), "pd.Series[str]"), pd.Series, str)
check(assert_type(left.radd(r1), "pd.Series[str]"), pd.Series, str)


def test_add_numpy_array() -> None:
"""Testpd.Series[str]+ numpy array"""
"""Test pd.Series[str] + numpy array"""
i = np.array([3, 5, 8], np.int64)
r0 = np.array(["a", "bc", "def"], np.str_)

Expand Down Expand Up @@ -96,12 +96,12 @@ def test_add_numpy_array() -> None:
check(assert_type(left.add(r0), "pd.Series[str]"), pd.Series, str)

if TYPE_CHECKING_INVALID_USAGE:
left.radd(i) # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
left.radd(i) # type: ignore[arg-type] # pyright: ignore[reportArgumentType, reportCallIssue]
check(assert_type(left.radd(r0), "pd.Series[str]"), pd.Series, str)


def test_add_pd_series() -> None:
"""Testpd.Series[str]+ pandas series"""
"""Test pd.Series[str] + pandas series"""
i = pd.Series([3, 5, 8])
r0 = pd.Series(["a", "bc", "def"])

Expand All @@ -118,5 +118,5 @@ def test_add_pd_series() -> None:
check(assert_type(left.add(r0), "pd.Series[str]"), pd.Series, str)

if TYPE_CHECKING_INVALID_USAGE:
left.radd(i) # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
left.radd(i) # type: ignore[arg-type] # pyright: ignore[reportArgumentType, reportCallIssue]
check(assert_type(left.radd(r0), "pd.Series[str]"), pd.Series, str)
Loading
Loading