Skip to content

Commit

Permalink
3 add config options docs (#9)
Browse files Browse the repository at this point in the history
* Add more tests
* Update docs
  • Loading branch information
makukha authored Aug 16, 2024
1 parent 7eb45ec commit 8db80f8
Show file tree
Hide file tree
Showing 6 changed files with 191 additions and 26 deletions.
2 changes: 1 addition & 1 deletion .bumpversion.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tool.bumpversion]
current_version = "0.1.1"
current_version = "0.1.2"
allow_dirty = true
files = [
{filename = "src/pydantic_file_secrets/__version__.py"},
Expand Down
96 changes: 77 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
# pydantic-file-secrets 📁🔑
> Use file secrets in nested models of Pydantic Settings.
# pydantic-file-secrets 🔑
> Use file secrets in nested [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) models.
![GitHub License](https://img.shields.io/github/license/makukha/pydantic-file-secrets)
[![Tests](https://raw.githubusercontent.com/makukha/pydantic-file-secrets/0.1.1/docs/badge/tests.svg)](https://github.com/makukha/pydantic-file-secrets)
[![Coverage](https://raw.githubusercontent.com/makukha/pydantic-file-secrets/0.1.1/docs/badge/coverage.svg)](https://github.com/makukha/pydantic-file-secrets)
[![Tests](https://raw.githubusercontent.com/makukha/pydantic-file-secrets/0.1.2/docs/badge/tests.svg)](https://github.com/makukha/pydantic-file-secrets)
[![Coverage](https://raw.githubusercontent.com/makukha/pydantic-file-secrets/0.1.2/docs/badge/coverage.svg)](https://github.com/makukha/pydantic-file-secrets)
[![linting - Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v1.json)](https://github.com/astral-sh/ruff)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) \
[![pypi](https://img.shields.io/pypi/v/pydantic-file-secrets.svg#0.1.1)](https://pypi.python.org/pypi/pydantic-file-secrets)
[![pypi](https://img.shields.io/pypi/v/pydantic-file-secrets.svg#0.1.2)](https://pypi.python.org/pypi/pydantic-file-secrets)
[![versions](https://img.shields.io/pypi/pyversions/pydantic-file-secrets.svg)](https://pypi.org/project/pydantic-file-secrets)
[![Pydantic v2](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/pydantic/pydantic/main/docs/badge/v2.json)](https://pydantic.dev)


This package is inspired by and based on discussions in [pydantic-settings issue #154](https://github.com/pydantic/pydantic-settings/issues/154).
This project is inspired by discussions in [pydantic-settings issue #154](https://github.com/pydantic/pydantic-settings/issues/154).

> This package unties secrets from environment variables config options and implements other long waited features.

## Features
Expand Down Expand Up @@ -47,7 +49,7 @@ class Settings(BaseSettings):
)
```

Pydantic Settings has a corresponding data source, [`SecretsSettingsSource`](https://docs.pydantic.dev/latest/api/pydantic_settings/#pydantic_settings.SecretsSettingsSource), but it does not load secrets in nested models. For methods that ***do not*** work in original Pydantic Settings, see [test_pydantic_motivation.py](https://github.com/makukha/pydantic-file-secrets/blob/main/tests/test_pydantic_motivation.py).
Pydantic Settings has a corresponding data source, [`SecretsSettingsSource`](https://docs.pydantic.dev/latest/api/pydantic_settings/#pydantic_settings.SecretsSettingsSource), but it does not load secrets in nested models. For things that DO NOT work in original Pydantic Settings, see [test_pydantic_motivation.py](https://github.com/makukha/pydantic-file-secrets/blob/main/tests/test_pydantic_motivation.py).


## Solution
Expand All @@ -60,12 +62,15 @@ The new `FileSecretsSettingsSource` is a drop-in replacement of stock `SecretsSe
$ pip install pydantic-file-secrets
```

### Plain directory layout
### Plain secrets directory layout

```text
# /run/secrets/app_key
secret1
| file | content |
|-----------------------------|-----------|
| `/run/secrets/app_key` | `secret1` |
| `/run/secrets/db__password` | `secret2` |
# /run/secrets/db__password
secret2
```

```python
from pydantic import BaseModel, Secret
Expand All @@ -84,6 +89,7 @@ class Settings(BaseSettings):
secrets_dir='/run/secrets',
env_nested_delimiter='__',
)

@classmethod
def settings_customise_sources(
cls,
Expand All @@ -101,14 +107,17 @@ class Settings(BaseSettings):

```

### Secrets in subdirectories
### Nested secrets directory layout

Config option `secrets_nested_delimiter` overrides `env_nested_delimiter` for files. In particular, this allows to use nested directory layout along with environmemt variables for other non-secret settings:

| file | content |
|----------------------------|-----------|
| `/run/secrets/app_key` | `secret1` |
| `/run/secrets/db/password` | `secret2` |
```text
# /run/secrets/app_key
secret1
# /run/secrets/db/password
secret2
```

```python
...
Expand All @@ -121,7 +130,50 @@ Config option `secrets_nested_delimiter` overrides `env_nested_delimiter` for fi

## Configuration options

TODO
### secrets_dir

Path to secrets directory, same as `SecretsSettingsSource.secrets_dir`.

### secrets_dir_missing

If `secrets_dir` does not exist, original `SecretsSettingsSource` issues a warning. However, this may be undesirable, for example if we don't mount Docker Secrets in e.g. dev environment. Now you have a choice:

* `'ok'` — do nothing if `secrets_dir` does not exist
* `'warn'` (default) — print warning, same as `SecretsSettingsSource`
* `'error'` — raise `SettingsError`

### secrets_dir_max_size

Limit the size of `secrets_dir` for security reasons, defaults to 8 MiB.

`FileSecretsSettingsSource` is a thin wrapper around [`EnvSettingsSource`](https://docs.pydantic.dev/latest/api/pydantic_settings/#pydantic_settings.EnvSettingsSource), which loads all potential secrets on initialization. This could lead to `MemoryError` if we mount a large file under `secrets_dir`.

### secrets_case_sensitive

Same as [`case_sensitive`](https://docs.pydantic.dev/latest/concepts/pydantic_settings/#case-sensitivity), but works for secrets only. If not specified, defaults to `case_sensitive`.

### secrets_nested_delimiter

Same as [`env_nested_delimiter`](https://docs.pydantic.dev/latest/concepts/pydantic_settings/#parsing-environment-variable-values), but works for secrets only. If not specified, defaults to `env_nested_delimiter`. This option is used to implement [nested secrets directory layout](https://github.com/makukha/pydantic-file-secrets?tab=readme-ov-file#nested-secrets-directory-layout) and allows to do even nastier things like `/run/secrets/model/delim/nested1/delim/nested2`.

### secrets_nested_subdir

Boolean flag to turn on nested secrets directory mode, `False` by default. If `True`, sets `secrets_nested_delimiter` to [`os.sep`](https://docs.python.org/3/library/os.html#os.sep). Raises `settingsError` if `secrets_nested_delimiter` is already specified.

### secrets_prefix

Secret path prefix, similar to `env_prefix`, but works for secrets only. Defaults to `env_prefix` if not specified. Works in both plain and nested directory modes, like `'/run/secrets/prefix_model__nested'` and `'/run/secrets/prefix_model/nested'`.


### Not supported config options

Some config options that are declared in [`SecretsSettingsSource`](https://docs.pydantic.dev/latest/api/pydantic_settings/#pydantic_settings.SecretsSettingsSource) interface are actually [not working](https://github.com/makukha/pydantic-file-secrets/blob/main/tests/test_pydantic_source.py) and are not supported in `FileSecretsSettingsSource`:

* `env_ignore_empty`
* `env_parse_none_str`
* `env_parse_enums`

However, we [make sure](https://github.com/makukha/pydantic-file-secrets/blob/main/tests/test_ignored_options.py) that the behaviour of `FileSecretsSettingsSource` matches `SecretsSettingsSource` to provide a drop-in replacement, although it is somewhat wierd (e.g. `env_parse_enums` is always `True`).


## Roadmap
Expand All @@ -130,4 +182,10 @@ TODO
* Per-field secret file name override.


## Changelog
## Authors

* [Michael Makukha](https://github.com/makukha)

## License

[MIT License](https://github.com/makukha/pydantic-file-secrets/blob/main/LICENSE)
8 changes: 4 additions & 4 deletions docs/badge/tests.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/pydantic_file_secrets/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '0.1.1'
__version__ = '0.1.2'
107 changes: 107 additions & 0 deletions tests/test_ignored_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""
These tests show that the behaviour of FileSecretsSettingsSource matches
SecretsSettingsSource for options env_ignore_empty, env_parse_none_str, env_parse_enums.
"""

from enum import StrEnum

from pydantic_settings import BaseSettings, SettingsConfigDict
import pytest

from pydantic_file_secrets import FileSecretsSettingsSource


class SomeEnum(StrEnum):
TEST = 'test'


class Settings(BaseSettings):
key_empty: str | None = None
key_none: str | None = None
key_enum: SomeEnum | None = None


class SettingsPairMaker:
def __call__(
self,
model_config: SettingsConfigDict,
) -> tuple[type[Settings], type[Settings]]:
class SettingsSSS(Settings): # SecretsSettingsSource
pass

class SettingsFSSS(Settings): # FileSecretsSettingsSource
@classmethod
def settings_customise_sources(
cls,
settings_cls,
init_settings,
env_settings,
dotenv_settings,
file_secret_settings,
) -> tuple:
return (
env_settings,
init_settings,
FileSecretsSettingsSource(settings_cls),
)

SettingsSSS.model_config = model_config
SettingsFSSS.model_config = model_config

return (SettingsSSS, SettingsFSSS)


@pytest.fixture()
def settings_models() -> SettingsPairMaker:
return SettingsPairMaker()


@pytest.fixture()
def populated_secrets_dir(secrets_dir):
secrets_dir.add_files(
('key_empty', ''),
('key_none', 'null'),
('key_enum', 'test'),
)
yield secrets_dir


@pytest.mark.parametrize(
'conf', [{}, {'env_ignore_empty': True}, {'env_ignore_empty': False}]
)
def test_env_ignore_empty(settings_models, conf, populated_secrets_dir):
SettingsSSS, SettingsFSSS = settings_models(
model_config=SettingsConfigDict(
secrets_dir=populated_secrets_dir,
**conf,
),
)
conf_sss, conf_fsss = SettingsSSS(), SettingsFSSS()
assert conf_sss.key_empty == conf_fsss.key_empty == ''


@pytest.mark.parametrize('conf', [{}, {'env_parse_none_str': 'null'}])
def test_env_parse_none_str(settings_models, conf, populated_secrets_dir):
SettingsSSS, SettingsFSSS = settings_models(
model_config=SettingsConfigDict(
secrets_dir=populated_secrets_dir,
**conf,
),
)
conf_sss, conf_fsss = SettingsSSS(), SettingsFSSS()
assert conf_sss.key_none == conf_fsss.key_none == 'null'


@pytest.mark.parametrize(
'conf', [{}, {'env_parse_enums': True}, {'env_parse_enums': False}]
)
def test_env_parse_enums(settings_models, conf, populated_secrets_dir):
SettingsSSS, SettingsFSSS = settings_models(
model_config=SettingsConfigDict(
secrets_dir=populated_secrets_dir,
**conf,
),
)
conf_sss, conf_fsss = SettingsSSS(), SettingsFSSS()
assert conf_sss.key_enum is SomeEnum.TEST
assert conf_fsss.key_enum is SomeEnum.TEST
2 changes: 1 addition & 1 deletion tests/test_pydantic_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pydantic_settings import BaseSettings, SettingsConfigDict


def test_not_working_env_ignore_empty(monkeypatch, secrets_dir):
def test_not_working_env_ignore_empty(secrets_dir):
class TestEnum(StrEnum):
TEST = 'test'

Expand Down

0 comments on commit 8db80f8

Please sign in to comment.