diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 028e7cf..4bf3732 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,6 +19,7 @@ jobs: fail-fast: false matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + pydantic-version: ["1.0", "2.0"] steps: - uses: actions/checkout@v3 @@ -43,7 +44,9 @@ jobs: ${{ runner.os }}-poetry- - name: Install dependencies - run: make install + run: | + make install + pip install 'pydantic~=${{ matrix.pydantic-version }}' - name: Run Linters run: poetry run make lint diff --git a/README.md b/README.md index c6c8479..28f68f3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [PEP-681](https://peps.python.org/pep-0681/)-compliant dataclass-like object, including but not limited to: -- [Pydantic models](https://pydantic-docs.helpmanual.io/) (v2+), +- [Pydantic models](https://pydantic-docs.helpmanual.io/) (v1/v2), - [dataclasses](https://docs.python.org/3/library/dataclasses.html) - [attrs classes](https://www.attrs.org/en/stable/). diff --git a/pyproject.toml b/pyproject.toml index e735a88..6801114 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "dataclass-settings" -version = "0.2.3" +version = "0.3.0" description = "Declarative dataclass settings." repository = "https://github.com/dancardin/dataclass-settings" diff --git a/src/dataclass_settings/class_inspect.py b/src/dataclass_settings/class_inspect.py index b5ceb29..db3e7a7 100644 --- a/src/dataclass_settings/class_inspect.py +++ b/src/dataclass_settings/class_inspect.py @@ -5,7 +5,7 @@ from typing import Any, Callable, Type import typing_inspect -from typing_extensions import Self, get_args, get_origin +from typing_extensions import Annotated, Self, get_args, get_origin, get_type_hints from dataclass_settings.loaders import Loader @@ -25,6 +25,7 @@ def detect(cls: type) -> bool: class ClassTypes(Enum): dataclass = "dataclass" pydantic = "pydantic" + pydantic_v1 = "pydantic_v1" pydantic_dataclass = "pydantic_dataclass" attrs = "attrs" @@ -37,16 +38,20 @@ def from_cls(cls, obj: type) -> ClassTypes: return cls.dataclass try: - from pydantic import BaseModel + import pydantic except ImportError: # pragma: no cover pass else: try: - is_base_model = issubclass(obj, BaseModel) + is_base_model = isinstance(obj, type) and issubclass( + obj, pydantic.BaseModel + ) except TypeError: is_base_model = False if is_base_model: + if pydantic.__version__.startswith("1."): + return cls.pydantic_v1 return cls.pydantic if hasattr(obj, "__attrs_attrs__"): @@ -69,11 +74,17 @@ class Field: def from_dataclass(cls, typ: Type) -> list[Self]: fields = [] for f in typ.__dataclass_fields__.values(): + type_ = get_origin(f.type) or f.type + args = get_args(f.type) or () + if type_ is Annotated: + type_, *_args = args + args = tuple(_args) + field = cls( name=f.name, - type=get_origin(f.type) or f.type, - annotations=get_args(f.type) or (), - mapper=f.type, + type=type_, + annotations=args, + mapper=type_, ) fields.append(field) return fields @@ -94,6 +105,23 @@ def from_pydantic(cls, typ: Type) -> list[Self]: fields.append(field) return fields + @classmethod + def from_pydantic_v1(cls, typ: Type) -> list[Self]: + fields = [] + type_hints = get_type_hints(typ, include_extras=True) + for name, f in typ.__fields__.items(): + annotation = get_type(type_hints[name]) + mapper = annotation if detect(annotation) else None + + field = cls( + name=name, + type=f.annotation, + annotations=get_args(annotation) or (), + mapper=mapper, + ) + fields.append(field) + return fields + @classmethod def from_pydantic_dataclass(cls, typ: Type) -> list[Self]: fields = [] @@ -167,6 +195,9 @@ def fields(cls: type): if class_type == ClassTypes.pydantic: return Field.from_pydantic(cls) + if class_type == ClassTypes.pydantic_v1: + return Field.from_pydantic_v1(cls) + if class_type == ClassTypes.pydantic_dataclass: return Field.from_pydantic_dataclass(cls) diff --git a/tests/class_types/test_pydantic.py b/tests/class_types/test_pydantic.py index 2ccc4f7..85297f7 100644 --- a/tests/class_types/test_pydantic.py +++ b/tests/class_types/test_pydantic.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from decimal import Decimal import pytest @@ -8,12 +10,13 @@ from tests.utils import env_setup -def test_missing_required(): - class Config(BaseModel): - foo: Annotated[str, Env("FOO")] +class MissingRequiredConfig(BaseModel): + foo: Annotated[str, Env("FOO")] + +def test_missing_required(): with env_setup({}), pytest.raises(ValidationError): - load_settings(Config) + load_settings(MissingRequiredConfig) def test_has_required_required(): @@ -27,17 +30,19 @@ class Config(BaseModel): assert config == Config(foo="1", ignoreme="asdf") -def test_nested(): - class Sub(BaseModel): - foo: Annotated[str, Env("FOO")] +class NestedSub(BaseModel): + foo: Annotated[str, Env("FOO")] - class Config(BaseModel): - sub: Sub +class NestedConfig(BaseModel): + sub: NestedSub + + +def test_nested(): with env_setup({"FOO": "3"}): - config = load_settings(Config) + config = load_settings(NestedConfig) - assert config == Config(sub=Sub(foo="3")) + assert config == NestedConfig(sub=NestedSub(foo="3")) def test_map_int(): diff --git a/tests/class_types/test_pydantic_dataclass.py b/tests/class_types/test_pydantic_dataclass.py index be509ae..5ce1479 100644 --- a/tests/class_types/test_pydantic_dataclass.py +++ b/tests/class_types/test_pydantic_dataclass.py @@ -9,13 +9,14 @@ from tests.utils import env_setup -def test_missing_required(): - @dataclass - class Config: - foo: Annotated[str, Env("FOO")] +@dataclass +class MissingRequiredConfig: + foo: Annotated[str, Env("FOO")] + - with env_setup({}), pytest.raises(ValidationError): - load_settings(Config) +def test_missing_required(): + with env_setup({}), pytest.raises((ValidationError, TypeError)): + load_settings(MissingRequiredConfig) def test_has_required_required():