diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0293943b..532f2453 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,9 +46,21 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Install bzip2 on macOS + if: matrix.os == 'macos' + run: brew install bzip2 + + + - name: Set environment variables + if: matrix.os == 'macos' + run: | + export LDFLAGS="-L/usr/local/opt/bzip2/lib" + export CPPFLAGS="-I/usr/local/opt/bzip2/include" + - run: | pip install -r requirements/pyproject.txt && pip install -r requirements/testing.txt + - run: pip freeze - run: make test diff --git a/docs/pandas_types.md b/docs/pandas_types.md new file mode 100644 index 00000000..4e6c9df6 --- /dev/null +++ b/docs/pandas_types.md @@ -0,0 +1,25 @@ + +The `Series` class provides support for working with pandas Series objects. + +```py +import pandas as pd +from pydantic import BaseModel + +from pydantic_extra_types.pandas_types import Series + + +class MyData(BaseModel): + numbers: Series + + +data = {"numbers": pd.Series([1, 2, 3, 4, 5])} +my_data = MyData(**data) + +print(my_data.numbers) +# > 0 1 +# > 1 2 +# > 2 3 +# > 3 4 +# > 4 5 +# > dtype: int64 +``` diff --git a/pydantic_extra_types/isbn.py b/pydantic_extra_types/isbn.py index df573c68..0e0ac6d7 100644 --- a/pydantic_extra_types/isbn.py +++ b/pydantic_extra_types/isbn.py @@ -1,7 +1,8 @@ """ The `pydantic_extra_types.isbn` module provides functionality to recieve and validate ISBN. -ISBN (International Standard Book Number) is a numeric commercial book identifier which is intended to be unique. This module provides a ISBN type for Pydantic models. +ISBN (International Standard Book Number) is a numeric commercial book identifier which is intended to be unique. +This module provides a ISBN type for Pydantic models. """ from __future__ import annotations diff --git a/pydantic_extra_types/pandas_types.py b/pydantic_extra_types/pandas_types.py new file mode 100644 index 00000000..914eed15 --- /dev/null +++ b/pydantic_extra_types/pandas_types.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import Any + +import pandas as pd +from pydantic import GetCoreSchemaHandler +from pydantic_core import core_schema + + +class Series(pd.Series): # type: ignore + @classmethod + def __get_pydantic_core_schema__( + cls, source: type[Any], handler: GetCoreSchemaHandler + ) -> core_schema.BeforeValidatorFunctionSchema: + return core_schema.general_before_validator_function( + cls._validate, + core_schema.any_schema(), + ) + + @classmethod + def _validate(cls, __input_value: Any, _: core_schema.ValidationInfo) -> Series: + return cls(__input_value) diff --git a/pyproject.toml b/pyproject.toml index d713e4d1..5f0aba01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,8 @@ dynamic = ['version'] [project.optional-dependencies] all = [ 'phonenumbers>=8,<9', + 'pycountry>=22,<23', + 'pandas==2.2.1', 'pycountry>=23', 'python-ulid>=1,<2; python_version<"3.9"', 'python-ulid>=1,<3; python_version>="3.9"', diff --git a/requirements/linting.in b/requirements/linting.in index 06a5fced..0cbcf032 100644 --- a/requirements/linting.in +++ b/requirements/linting.in @@ -2,3 +2,4 @@ pre-commit mypy annotated-types ruff +pandas-stubs diff --git a/requirements/linting.txt b/requirements/linting.txt index 9bc7bc16..eea1b0c9 100644 --- a/requirements/linting.txt +++ b/requirements/linting.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.10 # by the following command: # # pip-compile --no-emit-index-url --output-file=requirements/linting.txt requirements/linting.in @@ -20,6 +20,16 @@ mypy-extensions==1.0.0 # via mypy nodeenv==1.8.0 # via pre-commit +numpy==1.26.4 + # via pandas-stubs +pandas-stubs==2.2.0.240218 + # via -r requirements/linting.in +packaging==23.2 + # via black +pathspec==0.12.1 + # via -r requirements/linting.in +pyupgrade==3.15.0 + platformdirs==4.2.0 # via virtualenv pre-commit==3.6.2 diff --git a/requirements/pyproject.txt b/requirements/pyproject.txt index 5b774721..ed474de3 100644 --- a/requirements/pyproject.txt +++ b/requirements/pyproject.txt @@ -1,11 +1,13 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.10 # by the following command: # # pip-compile --extra=all --no-emit-index-url --output-file=requirements/pyproject.txt pyproject.toml # annotated-types==0.6.0 # via pydantic +pandas==2.0.3 + # via pydantic-extra-types (pyproject.toml) pendulum==3.0.0 # via pydantic-extra-types (pyproject.toml) phonenumbers==8.13.31 diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py index 5daa2460..3c170b2e 100644 --- a/tests/test_json_schema.py +++ b/tests/test_json_schema.py @@ -199,12 +199,7 @@ ( ISBN, { - 'properties': { - 'x': { - 'title': 'X', - 'type': 'string', - } - }, + 'properties': {'x': {'title': 'X'}}, 'required': ['x'], 'title': 'Model', 'type': 'object', diff --git a/tests/test_pandas_types.py b/tests/test_pandas_types.py new file mode 100644 index 00000000..c11f7cac --- /dev/null +++ b/tests/test_pandas_types.py @@ -0,0 +1,115 @@ +import pandas as pd +import pytest +from pydantic import BaseModel + +from pydantic_extra_types.pandas_types import Series + + +@pytest.fixture(scope='session', name='SeriesModel') +def series_model_fixture(): + class SeriesModel(BaseModel): + data: Series + + return SeriesModel + + +@pytest.mark.parametrize( + 'data, expected', + [ + ([1, 2, 3], [1, 2, 3]), + ([], []), + ([10, 20, 30, 40], [10, 20, 30, 40]), + ], +) +def test_series_creation(data, expected): + if pd.__version__ <= '1.5.3' and data == []: + s = Series([1]) + expected = [1] + else: + s = Series(data) + assert isinstance(s, Series) + assert isinstance(s, pd.Series) + assert s.tolist() == expected + + +def test_series_repr(): + data = [1, 2, 3] + s = Series(data) + assert repr(s) == repr(pd.Series(data)) + + +def test_series_attribute_access(): + data = [1, 2, 3] + s = Series(data) + assert s.sum() == pd.Series(data).sum() + + +def test_series_equality(): + data = [1, 2, 3] + s1 = Series(data) + s2 = Series(data) + assert s1.equals(other=s2) + assert s2.equals(pd.Series(data)) + + +def test_series_addition(): + data1 = [1, 2, 3] + data2 = [4, 5, 6] + s1 = Series(data1) + s2 = Series(data2) + s3 = s1 + s2 + assert isinstance(s3, pd.Series) + assert s3.tolist() == [5, 7, 9] + + +@pytest.mark.parametrize( + 'data, other, expected', + [ + ([1, 2, 3], [4, 5, 6], [5, 7, 9]), + ([10, 20, 30], (1, 2, 3), [11, 22, 33]), + ([5, 10, 15], pd.Series([1, 2, 3]), [6, 12, 18]), + ], +) +def test_series_addition_with_types(data, other, expected): + s = Series(data) + result = s + other + assert isinstance(result, pd.Series) + assert result.tolist() == expected + + +@pytest.mark.parametrize( + 'data, other', + [ + ([1, 2, 3], 'invalid'), # Invalid type for addition + ([1, 2, 3], {'a': 1, 'b': 2}), # Invalid type for addition + ], +) +def test_series_addition_invalid_type_error(data, other) -> None: + s = Series(data) + with pytest.raises(TypeError): + s + other + + +@pytest.mark.parametrize( + 'data, other', + [ + ([1, 2, 3], []), + ], +) +def test_series_addition_invalid_value_error(data, other) -> None: + s = Series(data) + with pytest.raises(ValueError): + s + other + + +def test_valid_series_model(SeriesModel) -> None: + model = SeriesModel(data=[1, 2, 4]) + assert isinstance(model.data, pd.Series) + assert model.data.equals(pd.Series([1, 2, 4])) + + +def test_valid_series_model_with_pd_series(SeriesModel) -> None: + s = pd.Series([1, 2, 4]) + model = SeriesModel(data=s) + assert isinstance(model.data, pd.Series) + assert model.data.equals(s)