Skip to content

Commit 85ddf8d

Browse files
committed
Adds support for optionally encrypted secrets
1 parent cc28c6d commit 85ddf8d

File tree

8 files changed

+186
-8
lines changed

8 files changed

+186
-8
lines changed

README.md

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ pixi add pydantic-settings-sops
2222
To use pydantic-settings-sops, adjust your settings sources by defining a custom `settings_customise_sources`.
2323
For more information on `pydantic-settings`, please visit the [official documentation](https://docs.pydantic.dev/latest/concepts/pydantic_settings).
2424

25+
If your settings are always encrypted with SOPS, you can use the following example:
26+
2527
```py
2628
from pydantic_settings import (
2729
BaseSettings,
@@ -31,22 +33,44 @@ from pydantic_settings import (
3133
from pydantic_settings_sops import SOPSConfigSettingsSource
3234

3335
class SettingsExample(BaseSettings):
34-
model_config = SettingsConfigDict(
35-
yaml_file="secrets.yaml"
36-
)
36+
model_config = SettingsConfigDict(yaml_file="secrets.yaml")
37+
38+
foobar: str
39+
40+
@classmethod
41+
def settings_customise_sources(
42+
cls,
43+
settings_cls: type[BaseSettings],
44+
init_settings: PydanticBaseSettingsSource,
45+
**other_settings: PydanticBaseSettingsSource,
46+
) -> tuple[PydanticBaseSettingsSource, ...]:
47+
return init_settings, SOPSConfigSettingsSource(settings_cls), *other_settings.values()
48+
```
49+
50+
If you need to handle JSON or YAML files that might be encrypted or not (i.e., encrypted in development but decrypted in
51+
production), you must use the specific source classes, `SopsYamlSettingsSource` or `SopsJsonSettingsSource`:
52+
53+
```py
54+
from pydantic_settings import (
55+
BaseSettings,
56+
PydanticBaseSettingsSource,
57+
SettingsConfigDict,
58+
)
59+
from pydantic_settings_sops import SopsYamlSettingsSource
60+
61+
class SettingsExample(BaseSettings):
62+
model_config = SettingsConfigDict(yaml_file="secrets.yaml")
3763

3864
foobar: str
3965

4066
@classmethod
4167
def settings_customise_sources(
4268
cls,
43-
settings_cls: BaseSettings,
69+
settings_cls: type[BaseSettings],
4470
init_settings: PydanticBaseSettingsSource,
45-
env_settings: PydanticBaseSettingsSource,
46-
dotenv_settings: PydanticBaseSettingsSource,
47-
file_secret_settings: PydanticBaseSettingsSource,
71+
**other_settings: PydanticBaseSettingsSource,
4872
) -> tuple[PydanticBaseSettingsSource, ...]:
49-
return (init_settings, SOPSConfigSettingsSource(settings_cls))
73+
return init_settings, SopsYamlSettingsSource(settings_cls), *other_settings.values()
5074
```
5175

5276
## Installation

pydantic_settings_sops/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import importlib.metadata
22
import warnings
33

4+
from .json import SopsJsonSettingsSource
5+
from .mixin import SopsConfigFileSourceMixin
6+
from .yaml import SopsYamlSettingsSource
7+
48
try:
59
__version__ = importlib.metadata.version(__name__)
610
except importlib.metadata.PackageNotFoundError as e: # pragma: no cover
@@ -49,3 +53,11 @@ def _read_file(self, file_path: Path) -> dict[str, Any]:
4953
decrypted = sops.decrypt(to_dict=True)
5054
assert isinstance(decrypted, dict)
5155
return decrypted
56+
57+
58+
__all__ = [
59+
"SOPSConfigSettingsSource",
60+
"SopsJsonSettingsSource",
61+
"SopsYamlSettingsSource",
62+
"SopsConfigFileSourceMixin",
63+
]

pydantic_settings_sops/json.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from pydantic_settings import BaseSettings, JsonConfigSettingsSource
2+
from pydantic_settings.sources import DEFAULT_PATH, PathType
3+
4+
from .mixin import SopsConfigFileSourceMixin
5+
6+
7+
class SopsJsonSettingsSource(SopsConfigFileSourceMixin, JsonConfigSettingsSource):
8+
"""
9+
A source that contains variables on a optionally encrypted SOPS JSON file
10+
"""
11+
12+
def __init__(
13+
self,
14+
settings_cls: type[BaseSettings],
15+
json_file: PathType | None = DEFAULT_PATH,
16+
json_file_encoding: str | None = None,
17+
*,
18+
allow_unencrypted: bool = True,
19+
):
20+
self.allow_unencrypted = allow_unencrypted
21+
super().__init__(settings_cls, json_file, json_file_encoding)

pydantic_settings_sops/mixin.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from abc import ABC
2+
from pathlib import Path
3+
from typing import Any
4+
5+
from pydantic_settings.sources import ConfigFileSourceMixin
6+
from sopsy import Sops, SopsyCommandFailedError
7+
8+
9+
class SopsConfigFileSourceMixin(ConfigFileSourceMixin, ABC):
10+
"""
11+
Mixin for reading configuration files optionally encrypted with SOPS.
12+
"""
13+
14+
allow_unencrypted: bool = True
15+
16+
def _read_file(self, file_path: Path) -> dict[str, Any]:
17+
try:
18+
sops = Sops(file_path)
19+
decrypted = sops.decrypt(to_dict=True)
20+
assert isinstance(decrypted, dict)
21+
return decrypted
22+
except SopsyCommandFailedError as exc:
23+
if self.allow_unencrypted and "sops metadata not found" in str(exc):
24+
return super()._read_file(file_path) # type: ignore[safe-super]
25+
raise

pydantic_settings_sops/yaml.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from pydantic_settings import BaseSettings, YamlConfigSettingsSource
2+
from pydantic_settings.sources import DEFAULT_PATH, PathType
3+
4+
from .mixin import SopsConfigFileSourceMixin
5+
6+
7+
class SopsYamlSettingsSource(SopsConfigFileSourceMixin, YamlConfigSettingsSource):
8+
"""
9+
A source that contains variables on a optionally encrypted SOPS YAML file
10+
"""
11+
12+
def __init__(
13+
self,
14+
settings_cls: type[BaseSettings],
15+
yaml_file: PathType | None = DEFAULT_PATH,
16+
yaml_file_encoding: str | None = None,
17+
yaml_config_section: str | None = None,
18+
*,
19+
allow_unencrypted: bool = True,
20+
):
21+
self.allow_unencrypted = allow_unencrypted
22+
super().__init__(
23+
settings_cls, yaml_file, yaml_file_encoding, yaml_config_section
24+
)

tests/resources/unencrypted.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"answer": 42}

tests/resources/unencrypted.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
answer: 42

tests/test_optional_yaml.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import pytest
2+
from pydantic_settings import (
3+
BaseSettings,
4+
PydanticBaseSettingsSource,
5+
SettingsConfigDict,
6+
)
7+
8+
from pydantic_settings_sops import SopsJsonSettingsSource, SopsYamlSettingsSource
9+
10+
11+
class ExampleSettingsYaml(BaseSettings):
12+
model_config = SettingsConfigDict(
13+
yaml_file=[
14+
"tests/resources/secrets.yaml",
15+
"tests/resources/secrets2.yaml",
16+
"tests/resources/unencrypted.yaml",
17+
]
18+
)
19+
20+
foobar: str
21+
foobar2: str
22+
answer: int
23+
24+
@classmethod
25+
def settings_customise_sources(
26+
cls,
27+
settings_cls: type[BaseSettings],
28+
init_settings: PydanticBaseSettingsSource,
29+
**rest: PydanticBaseSettingsSource,
30+
) -> tuple[PydanticBaseSettingsSource, ...]:
31+
return init_settings, SopsYamlSettingsSource(settings_cls), *rest.values()
32+
33+
34+
class ExampleSettingsJson(BaseSettings):
35+
model_config = SettingsConfigDict(
36+
json_file=[
37+
"tests/resources/secrets.json",
38+
"tests/resources/secrets2.json",
39+
"tests/resources/unencrypted.json",
40+
]
41+
)
42+
43+
foobar: str
44+
foobar2: str
45+
answer: int
46+
47+
@classmethod
48+
def settings_customise_sources(
49+
cls,
50+
settings_cls: type[BaseSettings],
51+
init_settings: PydanticBaseSettingsSource,
52+
**rest: PydanticBaseSettingsSource,
53+
) -> tuple[PydanticBaseSettingsSource, ...]:
54+
return init_settings, SopsJsonSettingsSource(settings_cls), *rest.values()
55+
56+
57+
@pytest.mark.parametrize("settings_cls", [ExampleSettingsYaml, ExampleSettingsJson])
58+
def test_basic_settings(settings_cls):
59+
settings = settings_cls()
60+
assert settings.foobar == "foo"
61+
assert settings.foobar2 == "foo"
62+
assert settings.answer == 42
63+
64+
65+
@pytest.mark.parametrize("settings_cls", [ExampleSettingsYaml, ExampleSettingsJson])
66+
def test_settings_with_override(settings_cls):
67+
settings2 = settings_cls(foobar="bar")
68+
assert settings2.foobar == "bar"
69+
assert settings2.foobar2 == "foo"
70+
assert settings2.answer == 42

0 commit comments

Comments
 (0)