From 926059cdb896cbac18abe1a0eba852c10ab11b3f Mon Sep 17 00:00:00 2001 From: Hasan Ramezani Date: Sun, 14 May 2023 19:13:35 +0330 Subject: [PATCH] Add pytest-examples and make examples in docs testable --- docs/index.md | 68 ++++++++++++++++------ requirements/testing.in | 1 + requirements/testing.txt | 7 +++ tests/test_docs.py | 123 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 18 deletions(-) create mode 100644 tests/test_docs.py diff --git a/docs/index.md b/docs/index.md index 48f679ea..a37eb110 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 ( @@ -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/' @@ -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/'), @@ -92,6 +94,7 @@ Case-sensitivity can be turned on through the `model_config`: ```py from pydantic import ConfigDict + from pydantic_settings import BaseSettings @@ -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 @@ -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', @@ -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 @@ -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): ... @@ -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') ``` @@ -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): ... @@ -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 @@ -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') ``` @@ -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 @@ -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 @@ -493,7 +516,7 @@ class Settings(BaseSettings): print(Settings()) -#> foobar='spam' +#> foobar='test' ``` ### Removing sources @@ -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 @@ -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 + """ ``` diff --git a/requirements/testing.in b/requirements/testing.in index b7f150a6..26a90044 100644 --- a/requirements/testing.in +++ b/requirements/testing.in @@ -2,3 +2,4 @@ coverage[toml] pytest pytest-mock pytest-sugar +pytest-examples diff --git a/requirements/testing.txt b/requirements/testing.txt index 47fc5de1..6ce69ef0 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -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 diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 00000000..a8f8251e --- /dev/null +++ b/tests/test_docs.py @@ -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)