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
3 changes: 2 additions & 1 deletion .flake8
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[flake8]
# D202 No blank lines allowed after function docstring: not compatible with black
ignore = E203, E501, W503, W503, D105, D202
# E721 Type comparison - intentional in __eq__ methods for exact type checks
ignore = E203, E501, W503, W503, D105, D202, E721
max-line-length = 88
max-complexity = 10
doctests = true
16 changes: 13 additions & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ on:

jobs:
test:

name: test (py${{ matrix.python-version }}, pydantic${{ matrix.pydantic-version }})
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10"]
python-version: ["3.10", "3.11", "3.12"]
pydantic-version: ["1", "2"]

steps:
- uses: actions/checkout@v2
Expand All @@ -21,10 +22,19 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- name: Install poetry
uses: abatilo/actions-poetry@v2.1.0
uses: abatilo/actions-poetry@v3.0.0
with:
poetry-version: 1.8.3
- name: Install dependencies
run: |
poetry install
- name: Install Pydantic ${{ matrix.pydantic-version }}
run: |
if [ "${{ matrix.pydantic-version }}" = "1" ]; then
poetry run pip install "pydantic>=1.9.0,<2.0"
else
poetry run pip install "pydantic>=2.0,<3.0"
fi
- name: Run checks and lint code
run: |
poetry run invoke check
Expand Down
2 changes: 2 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,8 @@ https://github.com/samuelcolvin/pydantic and climatecontrol also provides a
simple extension to use pydantic models directly (typing functionality mentioned
above works here as well).

Supports both Pydantic v1 (>=1.7.4) and v2 (>=2.0).

>>> from climatecontrol.ext.pydantic import Climate
>>>
>>> class SettingsSubSchema(BaseModel):
Expand Down
4 changes: 3 additions & 1 deletion climatecontrol/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,9 @@ def reload(self) -> None:
self._set_state(parsed, combined, fragments, self._updates)

def update(
self, update_data: Mapping = None, path: Union[str, int, Sequence] = None
self,
update_data: Optional[Mapping] = None,
path: Optional[Union[str, int, Sequence]] = None,
) -> None:
"""Update settings using a patch dictionary.

Expand Down
19 changes: 13 additions & 6 deletions climatecontrol/ext/pydantic.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
"""Climatecontrol extension for using pydantic schemas as source."""
"""Climatecontrol extension for using pydantic schemas as source.

Supports both Pydantic v1 (>=1.7.4) and v2 (>=2.0).
"""

from typing import Generic, Mapping, Type, TypeVar

Expand Down Expand Up @@ -32,14 +35,15 @@ def __init__(self, *args, model: Type[T], **kwargs):
Examples:

>>> from climatecontrol.ext.pydantic import Climate
>>> from pydantic import BaseModel, Field
>>>
>>> class SettingsSubSchema(BaseModel):
... d: int = 4
...
>>> class SettingsSchema(BaseModel):
... a: str = 'test'
... b: bool = False
... c: SettingsSubSchema = SettingsSubSchema()
... c: SettingsSubSchema = Field(default_factory=SettingsSubSchema)
...
>>> climate = Climate(model=SettingsSchema)
>>> # defaults are initialized automatically:
Expand All @@ -48,12 +52,15 @@ def __init__(self, *args, model: Type[T], **kwargs):
>>> climate.settings.c.d
4
>>> # Types are checked if given
>>> climate.update({'c': {'d': 'boom!'}})
>>> climate.update({'c': {'d': 'boom!'}}) # doctest: +ELLIPSIS, +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
pydantic.error_wrappers.ValidationError: 1 validation error for SettingsSchema
c -> d
value is not a valid integer (type=type_error.integer)
pydantic...ValidationError: 1 validation error...
...

Note:
This extension supports both Pydantic v1 (>=1.7.4) and v2 (>=2.0).
The error messages and formats may vary between versions.

See Also:
:module:`pydantic`: Used to initialize and check settings.
Expand Down
2 changes: 1 addition & 1 deletion environment.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: climatecontrol
dependencies:
- python=3.8
- python=3.10
- pip=19
- ipython
- pip:
Expand Down
965 changes: 478 additions & 487 deletions poetry.lock

Large diffs are not rendered by default.

25 changes: 12 additions & 13 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "climatecontrol"
version = "0.11.0"
version = "0.12.0"
description = "Python library for loading app configurations from files and/or namespaced environment variables."
authors = ["Davis Kirkendall <davis.e.kirkendall@gmail.com>"]
license = "MIT"
Expand All @@ -16,36 +16,35 @@ classifiers = [
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Utilities",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules",
]
include = ["LICENSE", "README.rst", "setup.py", "climatecontrol/py.typed"]

[tool.poetry.dependencies]
python = ">=3.7,<4.0"
wrapt = "^1.12"
python = ">=3.10,<4.0"
wrapt = "^1.14"
dacite = { version = "^1.6", optional = true }
pydantic = { version = "^1.7.4", optional = true }
pydantic = { version = ">=1.7.4,<3.0", optional = true }

[tool.poetry.dev-dependencies]
[tool.poetry.group.dev.dependencies]
pytest = "^6.2.2"
pytest-mock = "^3.5.1"
coverage = "^5.4"
tomli = "^2.0.1"
PyYAML = "^6.0"
PyYAML = "^6.0.1"
click = ">=8.0"
invoke = "^1.6.0"
invoke = "^2.0"
black = "^22.1.0"
mypy = "^0.910"
mypy = "^1.0"
isort = ">=5.10.1"
flake8 = "^4.0.1"
flake8 = "^6.0"
dacite = "^1.6.0" # for extras
pydantic = "^1.9.0" # for extras
pydantic = ">=1.9.0,<3.0" # for extras
types-PyYAML = "^6.0.4"
tomli-w = "^1.0.0"

Expand Down
4 changes: 2 additions & 2 deletions tests/ext/test_dataclasses.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
from dataclasses import dataclass
from dataclasses import dataclass, field

import dacite
import pytest
Expand Down Expand Up @@ -29,7 +29,7 @@ class C:

@dataclass
class A:
c: C = C()
c: C = field(default_factory=C)
a: int = 1
b: str = "yeah"

Expand Down
122 changes: 122 additions & 0 deletions tests/ext/test_pydantic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
import pytest

try:
from pydantic import BaseModel, ValidationError
except ImportError:
pytest.skip("pydantic not installed", allow_module_level=True)

from climatecontrol.ext.pydantic import Climate


def test_climate_simple(mock_empty_os_environ):
"""Test basic pydantic climate object."""

class A(BaseModel):
a: int = 1
b: str = "yeah"

climate = Climate(model=A)
assert climate.settings.a == 1
assert climate.settings.b == "yeah"


def test_climate(mock_empty_os_environ):
"""Test climate with pydantic models."""

class C(BaseModel):
d: str = "weee"
e: int = 0

class A(BaseModel):
c: C = C()
a: int = 1
b: str = "yeah"

climate = Climate(model=A)
assert climate.settings.c.d == "weee"
climate.update({"b": "changed"})
assert len(climate._fragments) == 1
assert climate.settings.b == "changed"
assert climate.settings.c.d == "weee"
assert climate.settings.c.e == 0

climate.update({"c": {"d": "test"}})
assert len(climate._fragments) == 2
assert climate.settings.c.d == "test"

climate.update({"c": C(d="test2")})
assert len(climate._fragments) == 3
assert climate.settings.c.d == "test2"

with pytest.raises(ValidationError):
# assigning a list to a "str" field should fail
climate.update({"c": {"d": [1, 2, 3]}})
# no update should have been performed.
assert len(climate._fragments) == 3


def test_climate_nested_models(mock_empty_os_environ):
"""Test climate with nested pydantic models."""

class D(BaseModel):
value: str = "nested"

class C(BaseModel):
d: D = D()
name: str = "middle"

class A(BaseModel):
c: C = C()
a: int = 1

climate = Climate(model=A)
assert climate.settings.c.d.value == "nested"
assert climate.settings.c.name == "middle"
assert climate.settings.a == 1

climate.update({"c": {"d": {"value": "updated"}}})
assert climate.settings.c.d.value == "updated"


def test_climate_type_validation(mock_empty_os_environ):
"""Test that pydantic type validation works."""

class A(BaseModel):
number: int
flag: bool = False

climate = Climate(model=A)

climate.update({"number": 42})
assert climate.settings.number == 42

climate.update({"flag": True})
assert climate.settings.flag is True

# String to int should work for valid integers
climate.update({"number": "123"})
assert climate.settings.number == 123

# Invalid type conversion should fail
with pytest.raises(ValidationError):
climate.update({"number": "not a number"})


def test_climate_with_defaults(mock_empty_os_environ):
"""Test climate with default values."""

class Settings(BaseModel):
host: str = "localhost"
port: int = 8080
debug: bool = False

climate = Climate(model=Settings)
assert climate.settings.host == "localhost"
assert climate.settings.port == 8080
assert climate.settings.debug is False

climate.update({"port": 3000, "debug": True})
assert climate.settings.host == "localhost" # unchanged
assert climate.settings.port == 3000
assert climate.settings.debug is True
2 changes: 1 addition & 1 deletion tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,7 @@ def test_settings_items(mock_empty_os_environ):
climate.update({"a": {"b": {"c": value}}})
assert climate.settings.a.b.c == value

for value in [{"new": "data"}, "blaaa", 100]:
for value in [{"new": "data"}, "blaaa", 100]: # type: ignore[assignment]
with pytest.raises(TypeError):
climate.settings.a.b.c[0] = value
climate.update({"a": {"b": {"c": [value]}}})
Expand Down