Skip to content

Commit

Permalink
Adding support for pydantic-settings > 2.2.0 (#38)
Browse files Browse the repository at this point in the history
* Fixing bug introduced by pydantic-settings 2.2.0+

* Adding wrapper for 2.2.0 update and switching file handling to use available sources

* Pinning to keep pydantic-settings minor version constrained given tendency to introduce breaking changes
  • Loading branch information
djpugh authored Feb 25, 2024
1 parent f3769f1 commit 793bf11
Show file tree
Hide file tree
Showing 10 changed files with 248 additions and 213 deletions.
15 changes: 7 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,10 @@ dependencies = [
"jinja2",
"logzero",
"orjson",
"packaging",
"pydantic[email]",
"pydantic-settings<2.2.2",
# This is not an ideal pin, but diue to the issue highlighted in https://github.com/pydantic/pydantic-settings/issues/245
# and the non-semver compliant versioning in pydantic-settings, we need to add this pin
# this version would change the API behaviour for nskit as it will also ignore additional
# inputs in the python initialisation so We will pin to version < 2.2.0
"pydantic-settings>=2.2.0, <2.3.0",
# The issue highlighted in https://github.com/pydantic/pydantic-settings/issues/245 has been mitigated by a wrapper and additional parameter for dotenv_allow in the model config
"python-dotenv",
"ruamel.yaml",
"tomlkit",
Expand Down Expand Up @@ -227,14 +225,15 @@ import-order-style="appnexus"

[tool.isort]
profile = "appnexus"
src_paths = ['src/']
known_first_party = [
src_paths = ["src/"]
known_first_party = ["nskit"
]

known_application = 'nskit*'
known_application = "nskit*"
force_alphabetical_sort_within_sections = true
force_sort_within_sections = true
reverse_relative = true
combine_as_imports = true

[tool.pytest]
python_classes = false
12 changes: 8 additions & 4 deletions src/nskit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,15 @@ def __get_version() -> str:
# Use the metadata
import sys
if sys.version_info.major >= 3 and sys.version_info.minor >= 8:
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as parse_version
from importlib.metadata import (
PackageNotFoundError,
version as parse_version,
)
else:
from importlib_metadata import PackageNotFoundError
from importlib_metadata import version as parse_version # type: ignore
from importlib_metadata import ( # type: ignore
PackageNotFoundError,
version as parse_version,
)
try:
version = parse_version("nskit")
except PackageNotFoundError:
Expand Down
75 changes: 64 additions & 11 deletions src/nskit/common/configuration/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,39 @@
"""
from __future__ import annotations

from typing import Any
from pathlib import Path
from typing import Any, Optional

from pydantic_settings import BaseSettings as _BaseSettings
from pydantic_settings import PydanticBaseSettingsSource as _PydanticBaseSettingsSource
from pydantic_settings import SettingsConfigDict as _SettingsConfigDict
from pydantic.config import ExtraValues
from pydantic_settings import (
BaseSettings as _BaseSettings,
PydanticBaseSettingsSource as _PydanticBaseSettingsSource,
SettingsConfigDict as _SettingsConfigDict,
)
from pydantic_settings.sources import PathType

from nskit.common.configuration.mixins import PropertyDumpMixin
from nskit.common.configuration.sources import FileConfigSettingsSource
from nskit.common.configuration.sources import (
DotEnvSettingsSource,
JsonConfigSettingsSource,
TomlConfigSettingsSource,
YamlConfigSettingsSource,
)
from nskit.common.io import json, toml, yaml


class SettingsConfigDict(_SettingsConfigDict):
"""Customised Settings Config Dict."""
dotenv_extra: Optional[ExtraValues] = 'ignore'
config_file: Optional[PathType] = None
config_file_encoding: Optional[str] = None


class BaseConfiguration(PropertyDumpMixin, _BaseSettings):
"""A Pydantic BaseSettings type object with Properties included in model dump, and yaml and toml integrations."""

model_config = _SettingsConfigDict(env_file_encoding='utf-8')
model_config = SettingsConfigDict(env_file_encoding='utf-8')

@classmethod
def settings_customise_sources(
cls,
settings_cls: type[_BaseSettings],
Expand All @@ -33,13 +49,50 @@ def settings_customise_sources(
file_secret_settings: _PydanticBaseSettingsSource,
) -> tuple[_PydanticBaseSettingsSource, ...]:
"""Create settings loading, including the FileConfigSettingsSource."""
# TODO This probably needs a tweak to handle complex structures.
config_files = cls.model_config.get('config_file')
config_file_encoding = cls.model_config.get('config_file_encoding')
file_types = {'json' : ['.json', '.jsn'],
'yaml': ['.yaml', '.yml'],
'toml': ['.toml', '.tml']}
if config_files:
if isinstance(config_files, (Path, str)):
config_files = [config_files]
else:
config_files = []

split_config_files = {}
for file_type, suffixes in file_types.items():
original = cls.model_config.get(f'{file_type}_file')
if original and isinstance(original, (Path, str)):
split_config_files[file_type] = [original]
elif original:
split_config_files[file_type] = original
else:
split_config_files[file_type] = []
for config_file in config_files:
if Path(config_file).suffix.lower() in suffixes:
split_config_files[file_type].append(config_file)
return (
FileConfigSettingsSource(settings_cls),
init_settings,
env_settings,
dotenv_settings,
file_secret_settings,
JsonConfigSettingsSource(settings_cls,
split_config_files['json'],
cls.model_config.get('json_file_encoding') or config_file_encoding),
YamlConfigSettingsSource(settings_cls,
split_config_files['yaml'],
cls.model_config.get('yaml_file_encoding') or config_file_encoding),
TomlConfigSettingsSource(settings_cls,
split_config_files['toml']),
DotEnvSettingsSource(settings_cls,
dotenv_settings.env_file,
dotenv_settings.env_file_encoding,
dotenv_settings.case_sensitive,
dotenv_settings.env_prefix,
dotenv_settings.env_nested_delimiter,
dotenv_settings.env_ignore_empty,
dotenv_settings.env_parse_none_str,
cls.model_config.get('dotenv_extra', 'ignore')),
file_secret_settings
)

def model_dump_toml(
Expand Down
156 changes: 97 additions & 59 deletions src/nskit/common/configuration/sources.py
Original file line number Diff line number Diff line change
@@ -1,69 +1,107 @@
"""Add settings sources."""
from __future__ import annotations as _annotations

from pathlib import Path
from typing import Any, Dict, Tuple
from typing import Any

from pydantic.fields import FieldInfo
from pydantic_settings import PydanticBaseSettingsSource
from pydantic.config import ExtraValues
from pydantic_settings import BaseSettings
from pydantic_settings.sources import (
DotEnvSettingsSource as _DotEnvSettingsSource,
DotenvType,
ENV_FILE_SENTINEL,
JsonConfigSettingsSource as _JsonConfigSettingsSource,
TomlConfigSettingsSource as _TomlConfigSettingsSource,
YamlConfigSettingsSource as _YamlConfigSettingsSource,
)

from nskit.common.io import json, toml, yaml


class FileConfigSettingsSource(PydanticBaseSettingsSource):
"""A simple settings source class that loads variables from a parsed file.
class JsonConfigSettingsSource(_JsonConfigSettingsSource):
"""Use the nskit.common.io.json loading to load settings from a json file."""
def _read_file(self, file_path: Path) -> dict[str, Any]:
encoding = self.json_file_encoding or 'utf-8'
file_contents = file_path.read_text(encoding)
return json.loads(file_contents)

def __call__(self):
"""Make the file reading at the source instantiation."""
self.init_kwargs = self._read_files(self.json_file_path)
return super().__call__()


class TomlConfigSettingsSource(_TomlConfigSettingsSource):
"""Use the nskit.common.io.toml loading to load settings from a toml file."""
def _read_file(self, file_path: Path) -> dict[str, Any]:
file_contents = file_path.read_text()
return toml.loads(file_contents)

def __call__(self):
"""Make the file reading at the source instantiation."""
self.init_kwargs = self._read_files(self.toml_file_path)
return super().__call__()


This can parse JSON, TOML, and YAML files based on the extensions.
class YamlConfigSettingsSource(_YamlConfigSettingsSource):
"""Use the nskit.common.io.yaml loading to load settings from a yaml file."""
def _read_file(self, file_path: Path) -> dict[str, Any]:
encoding = self.yaml_file_encoding or 'utf-8'
file_contents = file_path.read_text(encoding)
return yaml.loads(file_contents)

def __call__(self):
"""Make the file reading at the source instantiation."""
self.init_kwargs = self._read_files(self.yaml_file_path)
return super().__call__()


class DotEnvSettingsSource(_DotEnvSettingsSource):
"""Fixes change of behaviour in pydantic-settings 2.2.0 with extra allowed handling.
Adds dotenv_extra variable that is set to replicate previous behaviour (ignore).
"""

def __init__(self, *args, **kwargs):
"""Initialise the Settings Source."""
super().__init__(*args, **kwargs)
self.__parsed_contents = None

def get_field_value(
self, field: FieldInfo, field_name: str # noqa: U100
) -> Tuple[Any, str, bool]:
"""Get a field value."""
if self.__parsed_contents is None:
try:
encoding = self.config.get('env_file_encoding', 'utf-8')
file_path = Path(self.config.get('config_file_path'))
file_type = self.config.get('config_file_type', None)
file_contents = file_path.read_text(encoding)
if file_path.suffix.lower() in ['.jsn', '.json'] or (file_type is not None and file_type.lower() == 'json'):
self.__parsed_contents = json.loads(file_contents)
elif file_path.suffix.lower() in ['.tml', '.toml'] or (file_type is not None and file_type.lower() == 'toml'):
self.__parsed_contents = toml.loads(file_contents)
elif file_path.suffix.lower() in ['.yml', '.yaml'] or (file_type is not None and file_type.lower() == 'yaml'):
self.__parsed_contents = yaml.loads(file_contents)
except Exception:
pass # nosec B110
if self.__parsed_contents is not None:
field_value = self.__parsed_contents.get(field_name)
else:
field_value = None
return field_value, field_name, False

def prepare_field_value(
self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool # noqa: U100
) -> Any:
"""Prepare the field value."""
return value

def __call__(self) -> Dict[str, Any]:
"""Call the source."""
d: Dict[str, Any] = {}

for field_name, field in self.settings_cls.model_fields.items():
field_value, field_key, value_is_complex = self.get_field_value(
field, field_name
)
field_value = self.prepare_field_value(
field_name, field, field_value, value_is_complex
)
if field_value is not None:
d[field_key] = field_value

return d

def _load_file(self, file_path: Path, encoding: str) -> Dict[str, Any]: # noqa: U100
file_path = Path(file_path)
def __init__(
self,
settings_cls: type[BaseSettings],
env_file: DotenvType | None = ENV_FILE_SENTINEL,
env_file_encoding: str | None = None,
case_sensitive: bool | None = None,
env_prefix: str | None = None,
env_nested_delimiter: str | None = None,
env_ignore_empty: bool | None = None,
env_parse_none_str: str | None = None,
dotenv_extra: ExtraValues | None = 'ignore'
) -> None:
"""Wrapper for init function to add dotenv_extra handling."""
self.dotenv_extra = dotenv_extra
super().__init__(
settings_cls,
env_file,
env_file_encoding,
case_sensitive,
env_prefix,
env_nested_delimiter,
env_ignore_empty,
env_parse_none_str
)

def __call__(self) -> dict[str, Any]:
"""Wraps call logic introduced in 2.2.0, but is backwards compatible to 2.1.0 and earlier versions."""
data: dict[str, Any] = super().__call__()
to_pop = []
for key in data.keys():
matched = False
for field_name, field in self.settings_cls.model_fields.items():
for field_alias, field_env_name, _ in self._extract_field_info(field, field_name):
if key == field_env_name or key == field_alias:
matched = True
break
if matched:
break
if not matched and self.dotenv_extra == 'ignore':
to_pop.append(key)
for key in to_pop:
data.pop(key)
return data
11 changes: 3 additions & 8 deletions src/nskit/vcs/providers/azure_devops.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,17 @@
raise ImportError('Azure Devops Provider requires installing extra dependencies, use pip install nskit[azure_devops]')

from pydantic import HttpUrl
from pydantic_settings import SettingsConfigDict

from nskit.common.configuration import SettingsConfigDict
from nskit.vcs.providers.abstract import RepoClient, VCSProviderSettings

# Want to use MS interactive Auth as default, but can't get it working, instead, using cli invoke


class AzureDevOpsSettings(VCSProviderSettings):
"""Azure DevOps settings."""
# This is not ideal behaviour, but due to the issue highlighted in
# https://github.com/pydantic/pydantic-settings/issues/245 and the
# non-semver compliant versioning in pydantic-settings, we need to add this behaviour
# this now changes the API behaviour for these objects as they will
# also ignore additional inputs in the python initialisation
#  We will pin to version < 2.1.0 instead of allowing 2.2.0+ as it requires the code below:
model_config = SettingsConfigDict(env_prefix='AZURE_DEVOPS_', env_file='.env') # , extra='ignore') noqa: E800
model_config = SettingsConfigDict(env_prefix='AZURE_DEVOPS_', env_file='.env', dotenv_extra='ignore')

url: HttpUrl = "https://dev.azure.com"
organisation: str
project: str
Expand Down
14 changes: 4 additions & 10 deletions src/nskit/vcs/providers/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@
except ImportError:
raise ImportError('Github Provider requires installing extra dependencies (ghapi), use pip install nskit[github]')
from pydantic import Field, field_validator, HttpUrl, SecretStr, ValidationInfo
from pydantic_settings import SettingsConfigDict

from nskit.common.configuration import BaseConfiguration
from nskit.common.configuration import BaseConfiguration, SettingsConfigDict
from nskit.vcs.providers.abstract import RepoClient, VCSProviderSettings


class GithubRepoSettings(BaseConfiguration):
"""Github Repo settings."""
model_config = SettingsConfigDict(env_prefix='GITHUB_REPO', env_file='.env')
model_config = SettingsConfigDict(env_prefix='GITHUB_REPO_', env_file='.env', dotenv_extra='ignore')

private: bool = True
has_issues: Optional[bool] = None
Expand All @@ -36,13 +35,8 @@ class GithubSettings(VCSProviderSettings):
Uses PAT token for auth (set in environment variables as GITHUB_TOKEN)
"""
# This is not ideal behaviour, but due to the issue highlighted in
# https://github.com/pydantic/pydantic-settings/issues/245 and the
# non-semver compliant versioning in pydantic-settings, we need to add this behaviour
# this now changes the API behaviour for these objects as they will
# also ignore additional inputs in the python initialisation
# We will pin to version < 2.1.0 instead of allowing 2.2.0+ as it requires the code below:
model_config = SettingsConfigDict(env_prefix='GITHUB_', env_file='.env') # extra='ignore') noqa: E800
model_config = SettingsConfigDict(env_prefix='GITHUB_', env_file='.env', dotenv_extra='ignore')

interactive: bool = Field(False, description='Use Interactive Validation for token')
url: HttpUrl = "https://api.github.com"
organisation: Optional[str] = Field(None, description='Organisation to work in, otherwise uses the user for the token')
Expand Down
Loading

0 comments on commit 793bf11

Please sign in to comment.