Skip to content

Commit 3c2f4b6

Browse files
authored
Merge pull request #22 from DanCardin/dc/type-lens
feat: Adopt type-lens.
2 parents 9f18a4b + f39f96b commit 3c2f4b6

File tree

8 files changed

+169
-106
lines changed

8 files changed

+169
-106
lines changed

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
jame: Github Release/Publish PyPi
1+
name: Github Release/Publish PyPi
22

33
on:
44
push:

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 0.6
4+
5+
### 0.6.0
6+
7+
* feat: Adopt type-lens library to better handle 3.12-style annotations which
8+
were incorrectly identified by typing_inspect
9+
- fix: Ignore `ClassVar` annotated fields
10+
311
## 0.5
412

513
### 0.5.2

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "dataclass-settings"
3-
version = "0.5.3"
3+
version = "0.6.0"
44
description = "Declarative dataclass settings."
55

66
urls = { repository = "https://github.com/dancardin/dataclass-settings" }
@@ -14,7 +14,7 @@ requires-python = ">=3.8,<4"
1414

1515
dependencies = [
1616
"typing-extensions >= 4.7.1",
17-
"typing-inspect",
17+
"type-lens >= 0.2.5",
1818
]
1919

2020
[project.optional-dependencies]

src/dataclass_settings/base.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import logging
4-
from typing import Any, Sequence, TypeVar
4+
from typing import Any, ClassVar, Sequence, TypeVar
55

66
from dataclass_settings import class_inspect
77
from dataclass_settings.context import Context
@@ -80,6 +80,9 @@ def collect(
8080
) -> dict[str, Any] | None:
8181
result = {}
8282
for field in class_inspect.fields(source_cls):
83+
if field.type_view.fallback_origin is ClassVar:
84+
continue
85+
8386
field_context = context.enter(field.name)
8487

8588
value: str | dict[str, Any] | None = None

src/dataclass_settings/class_inspect.py

Lines changed: 50 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
import dataclasses
44
from enum import Enum
5-
from typing import Any, Callable, Tuple, Type
5+
from typing import Any, Callable, Sequence, Type
66

7-
import typing_inspect
8-
from typing_extensions import Annotated, Self, get_args, get_origin, get_type_hints
7+
from type_lens import TypeView
8+
from typing_extensions import Self, get_type_hints
99

1010
from dataclass_settings.loaders import Loader
1111

@@ -22,7 +22,7 @@ def detect(cls: type) -> bool:
2222
@dataclasses.dataclass
2323
class Field:
2424
name: str
25-
type: type
25+
type_view: TypeView
2626
annotations: tuple[Any, ...]
2727
mapper: Callable[..., Any] | None = None
2828

@@ -32,10 +32,10 @@ def get_loaders(self, loaders: tuple[Type[Loader], ...]):
3232
yield m
3333

3434
def get_nested_type(self) -> Type | None:
35-
if typing_inspect.is_union_type(self.type):
36-
args = typing_inspect.get_args(self.type)
35+
if self.type_view.is_union:
36+
args: Sequence[type] = self.type_view.args
3737
else:
38-
args = [self.type]
38+
args = [self.type_view.annotation]
3939

4040
unsupported_args = []
4141
try:
@@ -61,66 +61,57 @@ def map_value(self, value: str | dict[str, Any]):
6161

6262
return self.mapper(value)
6363

64+
@classmethod
65+
def from_type_view(cls, name: str, type_view: TypeView) -> Self:
66+
stripped = type_view.strip_optional()
67+
return cls(
68+
name=name,
69+
type_view=stripped,
70+
annotations=type_view.metadata,
71+
mapper=stripped.annotation,
72+
)
73+
6474

6575
@dataclasses.dataclass
6676
class DataclassField(Field):
6777
@classmethod
68-
def collect(cls, value: type, type_hints: dict[str, Type]) -> list[Self]:
78+
def collect(cls, value: type, type_hints: dict[str, TypeView]) -> list[Self]:
6979
fields = []
7080
for f in value.__dataclass_fields__.values(): # type: ignore
71-
annotation = get_type(type_hints[f.name])
72-
73-
annotation, args = get_annotation_args(annotation)
74-
75-
field = cls(
76-
name=f.name,
77-
type=annotation,
78-
annotations=args,
79-
mapper=annotation,
80-
)
81+
type_view = type_hints[f.name]
82+
field = cls.from_type_view(f.name, type_view)
8183
fields.append(field)
8284
return fields
8385

8486

8587
@dataclasses.dataclass
8688
class AttrsField(Field):
8789
@classmethod
88-
def collect(cls, value: type, type_hints: dict[str, Type]) -> list[Self]:
90+
def collect(cls, value: type, type_hints: dict[str, TypeView]) -> list[Self]:
8991
fields = []
9092

9193
for f in value.__attrs_attrs__: # type: ignore
92-
annotation = get_type(type_hints[f.name])
93-
annotation, args = get_annotation_args(annotation)
94-
95-
field = cls(
96-
name=f.name,
97-
type=annotation,
98-
annotations=args,
99-
mapper=annotation,
100-
)
94+
type_view = type_hints[f.name]
95+
field = cls.from_type_view(f.name, type_view)
10196
fields.append(field)
10297
return fields
10398

10499

105100
@dataclasses.dataclass
106101
class MsgspecField(Field):
107102
@classmethod
108-
def collect(cls, value: type, type_hints: dict[str, Type]) -> list[Self]:
103+
def collect(cls, value: type, type_hints: dict[str, TypeView]) -> list[Self]:
109104
import msgspec
110105

111106
fields = []
112107
for f in msgspec.structs.fields(value):
113-
annotation = get_type(type_hints[f.name])
114-
annotation, args = get_annotation_args(annotation)
115-
116-
mapper = cls.splat_mapper(annotation) if detect(annotation) else annotation
108+
type_view = type_hints[f.name]
109+
field = cls.from_type_view(f.name, type_view)
117110

118-
field = cls(
119-
name=f.name,
120-
type=annotation,
121-
annotations=args,
122-
mapper=mapper,
123-
)
111+
if detect(field.type_view.annotation):
112+
field = dataclasses.replace(
113+
field, mapper=cls.splat_mapper(type_view.annotation)
114+
)
124115
fields.append(field)
125116
return fields
126117

@@ -137,59 +128,45 @@ def convert(**value):
137128
@dataclasses.dataclass
138129
class PydanticV1Field(Field):
139130
@classmethod
140-
def collect(cls, value, type_hints: dict[str, Type]) -> list[Self]:
131+
def collect(cls, value, type_hints: dict[str, TypeView]) -> list[Self]:
141132
fields = []
142133
for name, f in value.__fields__.items():
143-
annotation = get_type(type_hints[name])
144-
annotation, args = get_annotation_args(annotation)
145-
146-
mapper = annotation if detect(annotation) else None
134+
type_view = type_hints[f.name]
135+
field = cls.from_type_view(name, type_view)
147136

148-
field = cls(
149-
name=name,
150-
type=annotation,
151-
annotations=args,
152-
mapper=mapper,
153-
)
137+
if not detect(field.type_view.annotation):
138+
field = dataclasses.replace(field, mapper=None)
154139
fields.append(field)
155140
return fields
156141

157142

158143
@dataclasses.dataclass
159144
class PydanticV2Field(Field):
160145
@classmethod
161-
def collect(cls, value: type, type_hints: dict[str, Type]) -> list[Self]:
146+
def collect(cls, value: type, type_hints: dict[str, TypeView]) -> list[Self]:
162147
fields = []
163148
for name, f in value.model_fields.items(): # type: ignore
164-
annotation = get_type(type_hints[name])
165-
mapper = annotation if detect(annotation) else None
166-
167-
field = cls(
168-
name=name,
169-
type=annotation,
170-
annotations=tuple(f.metadata),
171-
mapper=mapper,
172-
)
149+
type_view = type_hints[f.name]
150+
field = cls.from_type_view(name, type_view)
151+
152+
if not detect(field.type_view.annotation):
153+
field = dataclasses.replace(field, mapper=None)
173154
fields.append(field)
174155
return fields
175156

176157

177158
@dataclasses.dataclass
178159
class PydanticV2DataclassField(Field):
179160
@classmethod
180-
def collect(cls, value: type, type_hints: dict[str, Type]) -> list[Self]:
161+
def collect(cls, value: type, type_hints: dict[str, TypeView]) -> list[Self]:
181162
fields = []
182163

183164
for name, f in value.__pydantic_fields__.items(): # type: ignore
184-
annotation = get_type(type_hints[name])
185-
mapper = annotation if detect(annotation) else None
186-
187-
field = cls(
188-
name=name,
189-
type=annotation,
190-
annotations=tuple(f.metadata),
191-
mapper=mapper,
192-
)
165+
type_view = type_hints[name]
166+
field = cls.from_type_view(name, type_view)
167+
168+
if not detect(field.type_view.annotation):
169+
field = dataclasses.replace(field, mapper=None)
193170
fields.append(field)
194171
return fields
195172

@@ -202,7 +179,9 @@ def fields(cls: type):
202179
"Must be one of: dataclass, pydantic, or attrs class."
203180
)
204181

205-
type_hints = get_type_hints(cls, include_extras=True)
182+
type_hints = {
183+
k: TypeView(v) for k, v in get_type_hints(cls, include_extras=True).items()
184+
}
206185
return class_type.value.collect(cls, type_hints)
207186

208187

@@ -246,19 +225,3 @@ def from_cls(cls, obj: type) -> ClassTypes | None:
246225
return cls.attrs
247226

248227
return None
249-
250-
251-
def get_type(value):
252-
if typing_inspect.is_optional_type(value):
253-
return get_args(value)[0]
254-
return value
255-
256-
257-
def get_annotation_args(annotation) -> Tuple[Type, Tuple[Any, ...]]:
258-
args: Tuple[Any, ...] = ()
259-
if get_origin(annotation) is Annotated:
260-
args = get_args(annotation)
261-
annotation, *_args = args
262-
args = tuple(_args)
263-
264-
return annotation, args

tests/test_312_annotatations.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from dataclasses import dataclass
5+
6+
import pytest
7+
from attr import dataclass as attr_dataclass
8+
from msgspec import Struct
9+
from pydantic import BaseModel, ValidationError
10+
from pydantic.dataclasses import dataclass as pydantic_dataclass
11+
from typing_extensions import Annotated
12+
13+
from dataclass_settings import Env, load_settings
14+
from tests.utils import env_setup
15+
16+
if sys.version_info >= (3, 10):
17+
18+
@attr_dataclass
19+
class Attr:
20+
foo: Annotated[int | None, Env("FOO")] = None
21+
22+
@dataclass
23+
class Dataclass:
24+
foo: Annotated[int | None, Env("FOO")] = None
25+
26+
class Msgspec(Struct):
27+
foo: Annotated[int | None, Env("FOO")] = None
28+
29+
class Pydantic(BaseModel):
30+
foo: Annotated[int | None, Env("FOO")] = None
31+
32+
@pydantic_dataclass
33+
class PDataclass:
34+
foo: Annotated[int | None, Env("FOO")] = None
35+
36+
@pytest.mark.parametrize(
37+
"config_class, exc_class",
38+
[
39+
(Attr, TypeError),
40+
(Dataclass, TypeError),
41+
(Msgspec, TypeError),
42+
(Pydantic, ValidationError),
43+
(PDataclass, ValidationError),
44+
],
45+
)
46+
def test_handles_312_syntax(config_class, exc_class):
47+
with env_setup({}):
48+
result = load_settings(config_class)
49+
assert result.foo is None
50+
51+
with env_setup({"FOO": "5"}):
52+
result = load_settings(config_class)
53+
assert result.foo == 5

tests/test_classvar.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import ClassVar, Union
5+
6+
from typing_extensions import Annotated
7+
8+
from dataclass_settings import Env, load_settings
9+
from tests.utils import env_setup
10+
11+
12+
@dataclass
13+
class Dataclass:
14+
foo: ClassVar[int] = 5
15+
baz: Annotated[Union[int, None], Env("FOO")] = None
16+
17+
18+
def test_ignores_classvar():
19+
with env_setup({}):
20+
result = load_settings(Dataclass)
21+
assert result.foo == 5
22+
assert result.baz is None
23+
24+
with env_setup({"FOO": "5"}):
25+
result = load_settings(Dataclass)
26+
assert result.foo == 5
27+
assert result.baz == 5

0 commit comments

Comments
 (0)