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 926059c
Show file tree
Hide file tree
Showing 4 changed files with 181 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
123 changes: 123 additions & 0 deletions tests/test_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
from __future__ import annotations as _annotations

import os
import platform
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from tempfile import NamedTemporaryFile

import pytest
from pydantic.errors import PydanticErrorCodes
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 926059c

Please sign in to comment.