Skip to content

Commit

Permalink
Add pytest-examples and make examples in docs testable
Browse files Browse the repository at this point in the history
  • Loading branch information
hramezani committed May 15, 2023
1 parent 142b9cf commit ceaded4
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 18 deletions.
68 changes: 50 additions & 18 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This makes it easy to:
For example:

```py
from pprint import pprint
from typing import Any, Callable, Set

from pydantic import (
Expand All @@ -33,12 +34,12 @@ class SubModel(BaseModel):


class Settings(BaseSettings):
auth_key: str = Field(validation_alias='my_auth_key')
api_key: str = Field(validation_alias='my_api_key')
auth_key: str = Field('', validation_alias='my_auth_key')
api_key: str = Field('', validation_alias='my_api_key')

redis_dsn: RedisDsn = Field(
'redis://user:pass@localhost:6379/1',
validation_alias=AliasChoices('service_redis_dsn', 'redis_url')
validation_alias=AliasChoices('service_redis_dsn', 'redis_url'),
)
pg_dsn: PostgresDsn = 'postgres://user:pass@localhost:5432/foobar'
amqp_dsn: AmqpDsn = 'amqp://user:pass@localhost:5672/'
Expand All @@ -53,13 +54,14 @@ class Settings(BaseSettings):
# export my_prefix_more_settings='{"foo": "x", "apple": 1}'
more_settings: SubModel = SubModel()

model_config = ConfigDict(env_prefix = 'my_prefix_') # defaults to no prefix, i.e. ""
model_config = ConfigDict(env_prefix='my_prefix_') # defaults to no prefix, i.e. ""

print(Settings().model_dump())

pprint(Settings().model_dump())
"""
{
'auth_key': 'xxx',
'api_key': 'xxx',
'auth_key': '',
'api_key': '',
'redis_dsn': Url('redis://user:pass@localhost:6379/1'),
'pg_dsn': Url('postgres://user:pass@localhost:5432/foobar'),
'amqp_dsn': Url('amqp://user:pass@localhost:5672/'),
Expand Down Expand Up @@ -92,6 +94,7 @@ Case-sensitivity can be turned on through the `model_config`:

```py
from pydantic import ConfigDict

from pydantic_settings import BaseSettings


Expand Down Expand Up @@ -143,7 +146,11 @@ export SUB_MODEL__DEEP__V4=v4
You could load a settings module thus:

```py
import os
from pprint import pprint

from pydantic import BaseModel, ConfigDict

from pydantic_settings import BaseSettings


Expand All @@ -165,7 +172,14 @@ class Settings(BaseSettings):
model_config = ConfigDict(env_nested_delimiter='__')


print(Settings().model_dump())
# Set environment variables
os.environ['V0'] = '0'
os.environ['SUB_MODEL'] = '{"v1": "json-1", "v2": "json-2"}'
os.environ['SUB_MODEL__V2'] = 'nested-2'
os.environ['SUB_MODEL__V3'] = '3'
os.environ['SUB_MODEL__DEEP__V4'] = 'v4'

pprint(Settings().model_dump())
"""
{
'v0': '0',
Expand All @@ -177,6 +191,13 @@ print(Settings().model_dump())
},
}
"""

# Unset environment variables
os.environ.pop('V0')
os.environ.pop('SUB_MODEL')
os.environ.pop('SUB_MODEL__V2')
os.environ.pop('SUB_MODEL__V3')
os.environ.pop('SUB_MODEL__DEEP__V4')
```

`env_nested_delimiter` can be configured via the `model_config` as shown above, or via the
Expand Down Expand Up @@ -251,7 +272,7 @@ Once you have your `.env` file filled with variables, *pydantic* supports loadin
**1.** setting `env_file` (and `env_file_encoding` if you don't want the default encoding of your OS) on `model_config`
in a `BaseSettings` class:

```py
```py test="skip" lint="skip"
class Settings(BaseSettings):
...

Expand All @@ -261,7 +282,7 @@ class Settings(BaseSettings):
**2.** instantiating a `BaseSettings` derived class with the `_env_file` keyword argument
(and the `_env_file_encoding` if needed):

```py
```py test="skip" lint="skip"
settings = Settings(_env_file='prod.env', _env_file_encoding='utf-8')
```

Expand All @@ -285,7 +306,10 @@ If you need to load multiple dotenv files, you can pass the file paths as a `lis
Later files in the list/tuple will take priority over earlier files.

```py
from pydantic import BaseSettings
from pydantic import ConfigDict

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
...
Expand Down Expand Up @@ -320,7 +344,7 @@ Once you have your secret files, *pydantic* supports loading it in two ways:

**1.** setting `secrets_dir` on `model_config` in a `BaseSettings` class to the directory where your secret files are stored:

```py
```py test="skip" lint="skip"
class Settings(BaseSettings):
...
database_password: str
Expand All @@ -330,7 +354,7 @@ class Settings(BaseSettings):

**2.** instantiating a `BaseSettings` derived class with the `_secrets_dir` keyword argument:

```py
```py test="skip" lint="skip"
settings = Settings(_secrets_dir='/var/run')
```

Expand All @@ -352,7 +376,7 @@ and using secrets in Docker see the official
[Docker documentation](https://docs.docker.com/engine/reference/commandline/secret/).

First, define your Settings
```py
```py test="skip" lint="skip"
class Settings(BaseSettings):
my_secret_data: str

Expand Down Expand Up @@ -454,7 +478,6 @@ class JsonConfigSettingsSource(PydanticBaseSettingsSource):
fiel_value = file_content_json.get(field_name)
return fiel_value, field_name, False


def prepare_field_value(self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool) -> Any:
return value

Expand Down Expand Up @@ -493,7 +516,7 @@ class Settings(BaseSettings):


print(Settings())
#> foobar='spam'
#> foobar='test'
```

### Removing sources
Expand All @@ -503,6 +526,7 @@ You might also want to disable a source:
```py
from typing import Tuple, Type

from pydantic import ValidationError
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource


Expand All @@ -522,6 +546,14 @@ class Settings(BaseSettings):
return env_settings, file_secret_settings


print(Settings(my_api_key='this is ignored'))
# requires: `MY_API_KEY` env variable to be set, e.g. `export MY_API_KEY=xxx`
try:
Settings(my_api_key='this is ignored')
except ValidationError as exc_info:
print(exc_info)
"""
1 validation error for Settings
my_api_key
Field required [type=missing, input_value={}, input_type=dict]
For further information visit https://errors.pydantic.dev/2/v/missing
"""
```
1 change: 1 addition & 0 deletions requirements/testing.in
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ coverage[toml]
pytest
pytest-mock
pytest-sugar
pytest-examples
7 changes: 7 additions & 0 deletions requirements/testing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,25 @@ pluggy==1.0.0
pytest==7.3.1
# via
# -r requirements/testing.in
# pytest-examples
# pytest-mock
# pytest-sugar
pytest-examples==0.0.9
# via -r requirements/testing.in
pytest-mock==3.10.0
# via -r requirements/testing.in
pytest-sugar==0.9.7
# via -r requirements/testing.in
ruff==0.0.265
# via pytest-examples
termcolor==2.3.0
# via pytest-sugar
tomli==2.0.1
# via
# coverage
# pytest
typed-ast==1.5.4
# via black
typing-extensions==4.5.0
# via importlib-metadata
zipp==3.15.0
Expand Down
118 changes: 118 additions & 0 deletions tests/test_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
from __future__ import annotations as _annotations

import platform
import re
import sys
from pathlib import Path

import pytest
from pytest_examples import CodeExample, EvalExample, find_examples

INDEX_MAIN = None
DOCS_ROOT = Path(__file__).parent.parent / 'docs'


def skip_docs_tests():
if sys.platform not in {'linux', 'darwin'}:
return 'not in linux or macos'

if platform.python_implementation() != 'CPython':
return 'not cpython'


class GroupModuleGlobals:
def __init__(self) -> None:
self.name = None
self.module_dict: dict[str, str] = {}

def get(self, name: str | None):
if name is not None and name == self.name:
return self.module_dict

def set(self, name: str | None, module_dict: dict[str, str]):
self.name = name
if self.name is None:
self.module_dict = None
else:
self.module_dict = module_dict


group_globals = GroupModuleGlobals()

skip_reason = skip_docs_tests()


def print_callback(print_statement: str) -> str:
return re.sub(r'(https://errors.pydantic.dev)/.+?/', r'\1/2/', print_statement)


@pytest.mark.filterwarnings('ignore:(parse_obj_as|schema_json_of|schema_of) is deprecated.*:DeprecationWarning')
@pytest.mark.skipif(bool(skip_reason), reason=skip_reason or 'not skipping')
@pytest.mark.parametrize('example', find_examples(str(DOCS_ROOT), skip=sys.platform == 'win32'), ids=str)
def test_docs_examples(example: CodeExample, eval_example: EvalExample, tmp_path: Path, mocker): # noqa: C901
global INDEX_MAIN
if example.path.name == 'index.md':
if INDEX_MAIN is None:
INDEX_MAIN = example.source
else:
(tmp_path / 'index_main.py').write_text(INDEX_MAIN)
sys.path.append(str(tmp_path))

if example.path.name == 'devtools.md':
pytest.skip('tested below')

eval_example.print_callback = print_callback

prefix_settings = example.prefix_settings()
test_settings = prefix_settings.get('test')
lint_settings = prefix_settings.get('lint')
if test_settings == 'skip' and lint_settings == 'skip':
pytest.skip('both test and lint skipped')

requires_settings = prefix_settings.get('requires')
if requires_settings:
major, minor = map(int, requires_settings.split('.'))
if sys.version_info < (major, minor):
pytest.skip(f'requires python {requires_settings}')

group_name = prefix_settings.get('group')

if '# ignore-above' in example.source:
eval_example.set_config(ruff_ignore=['E402'])
if group_name:
eval_example.set_config(ruff_ignore=['F821'])

eval_example.set_config(line_length=120)
if lint_settings != 'skip':
if eval_example.update_examples:
eval_example.format(example)
else:
eval_example.lint(example)

if test_settings == 'skip':
return

group_name = prefix_settings.get('group')
d = group_globals.get(group_name)

xfail = None
if test_settings and test_settings.startswith('xfail'):
xfail = test_settings[5:].lstrip(' -')

rewrite_assertions = prefix_settings.get('rewrite_assert', 'true') == 'true'

try:
if test_settings == 'no-print-intercept':
d2 = eval_example.run(example, module_globals=d, rewrite_assertions=rewrite_assertions)
elif eval_example.update_examples:
d2 = eval_example.run_print_update(example, module_globals=d, rewrite_assertions=rewrite_assertions)
else:
d2 = eval_example.run_print_check(example, module_globals=d, rewrite_assertions=rewrite_assertions)
except BaseException as e: # run_print_check raises a BaseException
if xfail:
pytest.xfail(f'{xfail}, {type(e).__name__}: {e}')
raise
else:
if xfail:
pytest.fail('expected xfail')
group_globals.set(group_name, d2)

0 comments on commit ceaded4

Please sign in to comment.