diff --git a/docs/examples/settings_add_custom_source.py b/docs/examples/settings_add_custom_source.py deleted file mode 100644 index d0c2243..0000000 --- a/docs/examples/settings_add_custom_source.py +++ /dev/null @@ -1,41 +0,0 @@ -import json -from pathlib import Path -from typing import Dict, Any - -from pydantic import BaseSettings - - -def json_config_settings_source(settings: BaseSettings) -> Dict[str, Any]: - """ - A simple settings source that loads variables from a JSON file - at the project's root. - - Here we happen to choose to use the `env_file_encoding` from Config - when reading `config.json` - """ - encoding = settings.__config__.env_file_encoding - return json.loads(Path('config.json').read_text(encoding)) - - -class Settings(BaseSettings): - foobar: str - - class Config: - env_file_encoding = 'utf-8' - - @classmethod - def customise_sources( - cls, - init_settings, - env_settings, - file_secret_settings, - ): - return ( - init_settings, - json_config_settings_source, - env_settings, - file_secret_settings, - ) - - -print(Settings()) diff --git a/docs/examples/settings_case_sensitive.py b/docs/examples/settings_case_sensitive.py deleted file mode 100644 index 5030704..0000000 --- a/docs/examples/settings_case_sensitive.py +++ /dev/null @@ -1,8 +0,0 @@ -from pydantic import BaseSettings - - -class Settings(BaseSettings): - redis_host = 'localhost' - - class Config: - case_sensitive = True diff --git a/docs/examples/settings_disable_source.py b/docs/examples/settings_disable_source.py deleted file mode 100644 index fe5185d..0000000 --- a/docs/examples/settings_disable_source.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import Tuple - -from pydantic import BaseSettings -from pydantic.env_settings import SettingsSourceCallable - - -class Settings(BaseSettings): - my_api_key: str - - class Config: - @classmethod - def customise_sources( - cls, - init_settings: SettingsSourceCallable, - env_settings: SettingsSourceCallable, - file_secret_settings: SettingsSourceCallable, - ) -> Tuple[SettingsSourceCallable, ...]: - # here we choose to ignore arguments from init_settings - 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` diff --git a/docs/examples/settings_env_priority.py b/docs/examples/settings_env_priority.py deleted file mode 100644 index 44848a5..0000000 --- a/docs/examples/settings_env_priority.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import Tuple -from pydantic import BaseSettings, PostgresDsn -from pydantic.env_settings import SettingsSourceCallable - - -class Settings(BaseSettings): - database_dsn: PostgresDsn - - class Config: - @classmethod - def customise_sources( - cls, - init_settings: SettingsSourceCallable, - env_settings: SettingsSourceCallable, - file_secret_settings: SettingsSourceCallable, - ) -> Tuple[SettingsSourceCallable, ...]: - return env_settings, init_settings, file_secret_settings - - -print(Settings(database_dsn='postgres://postgres@localhost:5432/kwargs_db')) diff --git a/docs/examples/settings_main.py b/docs/examples/settings_main.py deleted file mode 100644 index 2ef6e46..0000000 --- a/docs/examples/settings_main.py +++ /dev/null @@ -1,49 +0,0 @@ -from typing import Set - -from pydantic import ( - BaseModel, - BaseSettings, - PyObject, - RedisDsn, - PostgresDsn, - AmqpDsn, - Field, -) - - -class SubModel(BaseModel): - foo = 'bar' - apple = 1 - - -class Settings(BaseSettings): - auth_key: str - api_key: str = Field(..., env='my_api_key') - - redis_dsn: RedisDsn = 'redis://user:pass@localhost:6379/1' - pg_dsn: PostgresDsn = 'postgres://user:pass@localhost:5432/foobar' - amqp_dsn: AmqpDsn = 'amqp://user:pass@localhost:5672/' - - special_function: PyObject = 'math.cos' - - # to override domains: - # export my_prefix_domains='["foo.com", "bar.com"]' - domains: Set[str] = set() - - # to override more_settings: - # export my_prefix_more_settings='{"foo": "x", "apple": 1}' - more_settings: SubModel = SubModel() - - class Config: - env_prefix = 'my_prefix_' # defaults to no prefix, i.e. "" - fields = { - 'auth_key': { - 'env': 'my_auth_key', - }, - 'redis_dsn': { - 'env': ['service_redis_dsn', 'redis_url'] - } - } - - -print(Settings().dict()) diff --git a/docs/examples/settings_nested_env.py b/docs/examples/settings_nested_env.py deleted file mode 100644 index 8e414d2..0000000 --- a/docs/examples/settings_nested_env.py +++ /dev/null @@ -1,23 +0,0 @@ -from pydantic import BaseModel, BaseSettings - - -class DeepSubModel(BaseModel): - v4: str - - -class SubModel(BaseModel): - v1: str - v2: bytes - v3: int - deep: DeepSubModel - - -class Settings(BaseSettings): - v0: str - sub_model: SubModel - - class Config: - env_nested_delimiter = '__' - - -print(Settings().dict()) diff --git a/docs/examples/settings_with_custom_parsing.py b/docs/examples/settings_with_custom_parsing.py deleted file mode 100644 index 9de2564..0000000 --- a/docs/examples/settings_with_custom_parsing.py +++ /dev/null @@ -1,19 +0,0 @@ -import os -from typing import Any, List - -from pydantic import BaseSettings - - -class Settings(BaseSettings): - numbers: List[int] - - class Config: - @classmethod - def parse_env_var(cls, field_name: str, raw_val: str) -> Any: - if field_name == 'numbers': - return [int(x) for x in raw_val.split(',')] - return cls.json_loads(raw_val) - - -os.environ['numbers'] = '1,2,3' -print(Settings().dict()) diff --git a/docs/index.md b/docs/index.md index c267e03..3d27eb5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,7 +7,7 @@ {!.version.md!} -Data validation and settings management using Python type annotations. +Data validation using Python type annotations. *pydantic* enforces type hints at runtime, and provides user friendly errors when data is invalid. @@ -107,11 +107,6 @@ So *pydantic* uses some cool new language features, but why should I actually go so auto-completion, linting, [mypy](usage/mypy.md), IDEs (especially [PyCharm](pycharm_plugin.md)), and your intuition should all work properly with your validated data. -**dual use** -: *pydantic's* [BaseSettings](usage/settings.md) class allows *pydantic* to be used in both a "validate this request - data" context and in a "load my system settings" context. The main differences are that system settings can - be read from environment variables, and more complex objects like DSNs and Python objects are often required. - **fast** : *pydantic* has always taken performance seriously, most of the library is compiled with cython giving a ~50% speedup, it's generally as fast or faster than most similar libraries. diff --git a/docs/install.md b/docs/install.md index a78e728..5ed894a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -52,23 +52,16 @@ CFLAGS="-Os -g0 -s" pip install \ ## Optional dependencies -*pydantic* has two optional dependencies: +*pydantic* has one optional dependencies: * If you require email validation you can add [email-validator](https://github.com/JoshData/python-email-validator) -* [dotenv file support](usage/settings.md#dotenv-env-support) with `Settings` requires - [python-dotenv](https://pypi.org/project/python-dotenv) To install these along with *pydantic*: ```bash pip install pydantic[email] -# or -pip install pydantic[dotenv] -# or just -pip install pydantic[email,dotenv] ``` -Of course, you can also install these requirements manually with `pip install email-validator` and/or `pip install python-dotenv`. - +Of course, you can also install these requirements manually with `pip install email-validator`. ## Install from repository @@ -76,5 +69,5 @@ And if you prefer to install *pydantic* directly from the repository: ```bash pip install git+git://github.com/pydantic/pydantic@main#egg=pydantic # or with extras -pip install git+git://github.com/pydantic/pydantic@main#egg=pydantic[email,dotenv] +pip install git+git://github.com/pydantic/pydantic@main#egg=pydantic[email] ``` diff --git a/docs/mypy_plugin.md b/docs/mypy_plugin.md index 6245577..d1b547a 100644 --- a/docs/mypy_plugin.md +++ b/docs/mypy_plugin.md @@ -31,8 +31,6 @@ There are other benefits too! See below for more details. keyword arguments. * If `Config.allow_population_by_field_name=True`, the generated signature will use the field names, rather than aliases. -* For subclasses of [`BaseSettings`](usage/settings.md), all fields are treated as optional since they may be - read from the environment. * If `Config.extra="forbid"` and you don't make use of dynamically-determined aliases, the generated signature will not allow unexpected inputs. * **Optional:** If the [`init_forbid_extra` **plugin setting**](#plugin-settings) is set to `True`, unexpected inputs to @@ -94,31 +92,6 @@ The plugin is compatible with mypy versions `>=0.930`. See the [mypy usage](usage/mypy.md) and [plugin configuration](#configuring-the-plugin) docs for more details. -### Plugin Settings - -The plugin offers a few optional strictness flags if you want even stronger checks: - -* `init_forbid_extra` - - If enabled, disallow extra arguments to the `__init__` call even when `Config.extra` is not `"forbid"`. - -* `init_typed` - - If enabled, include the field types as type hints in the generated signature for the `__init__` method. - This means that you'll get mypy errors if you pass an argument that is not already the right type to - `__init__`, even if parsing could safely convert the type. - -* `warn_required_dynamic_aliases` - - If enabled, raise a mypy error whenever a model is created for which - calls to its `__init__` or `construct` methods require the use of aliases that cannot be statically determined. - This is the case, for example, if `allow_population_by_field_name=False` and the model uses an alias generator. - -* `warn_untyped_fields` - - If enabled, raise a mypy error whenever a field is declared on a model without explicitly specifying its type. - - #### Configuring the Plugin To change the values of the plugin settings, create a section in your mypy config file called `[pydantic-mypy]`, and add any key-value pairs for settings you want to override. diff --git a/docs/usage/settings.md b/docs/usage/settings.md deleted file mode 100644 index 7268e9f..0000000 --- a/docs/usage/settings.md +++ /dev/null @@ -1,292 +0,0 @@ -One of pydantic's most useful applications is settings management. - -If you create a model that inherits from `BaseSettings`, the model initialiser will attempt to determine -the values of any fields not passed as keyword arguments by reading from the environment. (Default values -will still be used if the matching environment variable is not set.) - -This makes it easy to: - -* Create a clearly-defined, type-hinted application configuration class -* Automatically read modifications to the configuration from environment variables -* Manually override specific settings in the initialiser where desired (e.g. in unit tests) - -For example: - -{!.tmp_examples/settings_main.md!} - -## Environment variable names - -The following rules are used to determine which environment variable(s) are read for a given field: - -* By default, the environment variable name is built by concatenating the prefix and field name. - * For example, to override `special_function` above, you could use: - - export my_prefix_special_function='foo.bar' - - * Note 1: The default prefix is an empty string. - * Note 2: Field aliases are ignored when building the environment variable name. - -* Custom environment variable names can be set in two ways: - * `Config.fields['field_name']['env']` (see `auth_key` and `redis_dsn` above) - * `Field(..., env=...)` (see `api_key` above) -* When specifying custom environment variable names, either a string or a list of strings may be provided. - * When specifying a list of strings, order matters: the first detected value is used. - * For example, for `redis_dsn` above, `service_redis_dsn` would take precedence over `redis_url`. - -!!! warning - Since **v1.0** *pydantic* does not consider field aliases when finding environment variables to populate settings - models, use `env` instead as described above. - - To aid the transition from aliases to `env`, a warning will be raised when aliases are used on settings models - without a custom env var name. If you really mean to use aliases, either ignore the warning or set `env` to - suppress it. - -Case-sensitivity can be turned on through the `Config`: - -{!.tmp_examples/settings_case_sensitive.md!} - -When `case_sensitive` is `True`, the environment variable names must match field names (optionally with a prefix), -so in this example -`redis_host` could only be modified via `export redis_host`. If you want to name environment variables -all upper-case, you should name attribute all upper-case too. You can still name environment variables anything -you like through `Field(..., env=...)`. - -In Pydantic **v1** `case_sensitive` is `False` by default and all variable names are converted to lower-case internally. -If you want to define upper-case variable names on nested models like `SubModel` you have to -set `case_sensitive=True` to disable this behaviour. - -!!! note - On Windows, Python's `os` module always treats environment variables as case-insensitive, so the - `case_sensitive` config setting will have no effect - settings will always be updated ignoring case. - -## Parsing environment variable values - -For most simple field types (such as `int`, `float`, `str`, etc.), -the environment variable value is parsed the same way it would -be if passed directly to the initialiser (as a string). - -Complex types like `list`, `set`, `dict`, and sub-models are populated from the environment -by treating the environment variable's value as a JSON-encoded string. - -Another way to populate nested complex variables is to configure your model with the `env_nested_delimiter` -config setting, then use an env variable with a name pointing to the nested module fields. -What it does is simply explodes your variable into nested models or dicts. -So if you define a variable `FOO__BAR__BAZ=123` it will convert it into `FOO={'BAR': {'BAZ': 123}}` -If you have multiple variables with the same structure they will be merged. - -With the following environment variables: -```bash -# your environment -export V0=0 -export SUB_MODEL='{"v1": "json-1", "v2": "json-2"}' -export SUB_MODEL__V2=nested-2 -export SUB_MODEL__V3=3 -export SUB_MODEL__DEEP__V4=v4 -``` - -You could load a settings module thus: -{!.tmp_examples/settings_nested_env.md!} - -`env_nested_delimiter` can be configured via the `Config` class as shown above, or via the -`_env_nested_delimiter` keyword argument on instantiation. - -JSON is only parsed in top-level fields, if you need to parse JSON in sub-models, you will need to implement -validators on those models. - -Nested environment variables take precedence over the top-level environment variable JSON -(e.g. in the example above, `SUB_MODEL__V2` trumps `SUB_MODEL`). - -You may also populate a complex type by providing your own parsing function to -the `parse_env_var` classmethod in the Config object. - -{!.tmp_examples/settings_with_custom_parsing.md!} - -## Dotenv (.env) support - -!!! note - dotenv file parsing requires [python-dotenv](https://pypi.org/project/python-dotenv/) to be installed. - This can be done with either `pip install python-dotenv` or `pip install pydantic[dotenv]`. - -Dotenv files (generally named `.env`) are a common pattern that make it easy to use environment variables in a -platform-independent manner. - -A dotenv file follows the same general principles of all environment variables, -and looks something like: - -```bash -# ignore comment -ENVIRONMENT="production" -REDIS_ADDRESS=localhost:6379 -MEANING_OF_LIFE=42 -MY_VAR='Hello world' -``` - -Once you have your `.env` file filled with variables, *pydantic* supports loading it in two ways: - -**1.** setting `env_file` (and `env_file_encoding` if you don't want the default encoding of your OS) on `Config` -in a `BaseSettings` class: - -```py -class Settings(BaseSettings): - ... - - class Config: - env_file = '.env' - env_file_encoding = 'utf-8' -``` - -**2.** instantiating a `BaseSettings` derived class with the `_env_file` keyword argument -(and the `_env_file_encoding` if needed): - -```py -settings = Settings(_env_file='prod.env', _env_file_encoding='utf-8') -``` - -In either case, the value of the passed argument can be any valid path or filename, either absolute or relative to the -current working directory. From there, *pydantic* will handle everything for you by loading in your variables and -validating them. - -!!! note - If a filename is specified for `env_file`, Pydantic will only check the current working directory and - won't check any parent directories for the `.env` file. - -Even when using a dotenv file, *pydantic* will still read environment variables as well as the dotenv file, -**environment variables will always take priority over values loaded from a dotenv file**. - -Passing a file path via the `_env_file` keyword argument on instantiation (method 2) will override -the value (if any) set on the `Config` class. If the above snippets were used in conjunction, `prod.env` would be loaded -while `.env` would be ignored. - -If you need to load multiple dotenv files, you can pass the file paths as a `list` or `tuple`. - -Later files in the list/tuple will take priority over earlier files. - -```py -from pydantic import BaseSettings - -class Settings(BaseSettings): - ... - - class Config: - # `.env.prod` takes priority over `.env` - env_file = '.env', '.env.prod' -``` - -You can also use the keyword argument override to tell Pydantic not to load any file at all (even if one is set in -the `Config` class) by passing `None` as the instantiation keyword argument, e.g. `settings = Settings(_env_file=None)`. - -Because python-dotenv is used to parse the file, bash-like semantics such as `export` can be used which -(depending on your OS and environment) may allow your dotenv file to also be used with `source`, -see [python-dotenv's documentation](https://saurabh-kumar.com/python-dotenv/#usages) for more details. - -## Secret Support - -Placing secret values in files is a common pattern to provide sensitive configuration to an application. - -A secret file follows the same principal as a dotenv file except it only contains a single value and the file name -is used as the key. A secret file will look like the following: - -`/var/run/database_password`: -``` -super_secret_database_password -``` - -Once you have your secret files, *pydantic* supports loading it in two ways: - -**1.** setting `secrets_dir` on `Config` in a `BaseSettings` class to the directory where your secret files are stored: - -```py -class Settings(BaseSettings): - ... - database_password: str - - class Config: - secrets_dir = '/var/run' -``` - -**2.** instantiating a `BaseSettings` derived class with the `_secrets_dir` keyword argument: - -```py -settings = Settings(_secrets_dir='/var/run') -``` - -In either case, the value of the passed argument can be any valid directory, either absolute or relative to the -current working directory. **Note that a non existent directory will only generate a warning**. -From there, *pydantic* will handle everything for you by loading in your variables and validating them. - -Even when using a secrets directory, *pydantic* will still read environment variables from a dotenv file or the environment, -**a dotenv file and environment variables will always take priority over values loaded from the secrets directory**. - -Passing a file path via the `_secrets_dir` keyword argument on instantiation (method 2) will override -the value (if any) set on the `Config` class. - -### Use Case: Docker Secrets - -Docker Secrets can be used to provide sensitive configuration to an application running in a Docker container. -To use these secrets in a *pydantic* application the process is simple. More information regarding creating, managing -and using secrets in Docker see the official -[Docker documentation](https://docs.docker.com/engine/reference/commandline/secret/). - -First, define your Settings -```py -class Settings(BaseSettings): - my_secret_data: str - - class Config: - secrets_dir = '/run/secrets' -``` -!!! note - By default Docker uses `/run/secrets` as the target mount point. If you want to use a different location, change - `Config.secrets_dir` accordingly. - -Then, create your secret via the Docker CLI -```bash -printf "This is a secret" | docker secret create my_secret_data - -``` - -Last, run your application inside a Docker container and supply your newly created secret -```bash -docker service create --name pydantic-with-secrets --secret my_secret_data pydantic-app:latest -``` - -## Field value priority - -In the case where a value is specified for the same `Settings` field in multiple ways, -the selected value is determined as follows (in descending order of priority): - -1. Arguments passed to the `Settings` class initialiser. -2. Environment variables, e.g. `my_prefix_special_function` as described above. -3. Variables loaded from a dotenv (`.env`) file. -4. Variables loaded from the secrets directory. -5. The default field values for the `Settings` model. - -## Customise settings sources - -If the default order of priority doesn't match your needs, it's possible to change it by overriding -the `customise_sources` method on the `Config` class of your `Settings` . - -`customise_sources` takes three callables as arguments and returns any number of callables as a tuple. In turn these -callables are called to build the inputs to the fields of the settings class. - -Each callable should take an instance of the settings class as its sole argument and return a `dict`. - -### Changing Priority - -The order of the returned callables decides the priority of inputs; first item is the highest priority. - -{!.tmp_examples/settings_env_priority.md!} - -By flipping `env_settings` and `init_settings`, environment variables now have precedence over `__init__` kwargs. - -### Adding sources - -As explained earlier, *pydantic* ships with multiples built-in settings sources. However, you may occasionally -need to add your own custom sources, `customise_sources` makes this very easy: - -{!.tmp_examples/settings_add_custom_source.md!} - -### Removing sources - -You might also want to disable a source: - -{!.tmp_examples/settings_disable_source.md!} diff --git a/docs/visual_studio_code.md b/docs/visual_studio_code.md index 9368ba7..d8af738 100644 --- a/docs/visual_studio_code.md +++ b/docs/visual_studio_code.md @@ -257,21 +257,6 @@ in a model that is "frozen". ![VS Code strict type errors with model](./img/vs_code_08.png) -## BaseSettings and ignoring Pylance/pyright errors - -Pylance/pyright does not work well with [`BaseSettings`](./usage/settings.md) - fields in settings classes can be -configured via environment variables and therefore "required" fields do not have to be explicitly set when -initialising a settings instance. However, pyright considers these fields as "required" and will therefore -show an error when they're not set. - -See [#3753](https://github.com/pydantic/pydantic/issues/3753#issuecomment-1087417884) for an explanation of the -reasons behind this, and why we can't avoid the problem. - -There are two potential workarounds: - -* use an ignore comment (`# pyright: ignore`) when initialising `settings` -* or, use `settings.parse_obj({})` to avoid the warning - ## Adding a default with `Field` Pylance/pyright requires `default` to be a keyword argument to `Field` in order to infer that the field is optional. @@ -287,7 +272,7 @@ class Knight(BaseModel): lance = Knight() # error: Argument missing for parameter "age" ``` -Like the issue with `BaseSettings`, this is a limitation of dataclass transforms and cannot be fixed in pydantic. +This is a limitation of dataclass transforms and cannot be fixed in pydantic. ## Technical Details diff --git a/mkdocs.yml b/mkdocs.yml index 301e4be..146594f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -52,7 +52,6 @@ nav: - usage/exporting_models.md - usage/dataclasses.md - usage/validation_decorator.md - - 'Settings management': usage/settings.md - usage/postponed_annotations.md - 'Usage with mypy': usage/mypy.md - 'Usage with devtools': usage/devtools.md diff --git a/pydantic/__init__.py b/pydantic/__init__.py index db13046..b87b049 100644 --- a/pydantic/__init__.py +++ b/pydantic/__init__.py @@ -4,7 +4,6 @@ from .annotated_types import create_model_from_namedtuple, create_model_from_typ from .class_validators import root_validator, validator from .config import BaseConfig, ConfigDict, Extra from .decorator import validate_arguments -from .env_settings import BaseSettings from .error_wrappers import ValidationError from .errors import * from .fields import Field, PrivateAttr, Required @@ -34,8 +33,6 @@ __all__ = [ 'Extra', # decorator 'validate_arguments', - # env_settings - 'BaseSettings', # error_wrappers 'ValidationError', # fields diff --git a/pydantic/env_settings.py b/pydantic/env_settings.py deleted file mode 100644 index e9988c0..0000000 --- a/pydantic/env_settings.py +++ /dev/null @@ -1,346 +0,0 @@ -import os -import warnings -from pathlib import Path -from typing import AbstractSet, Any, Callable, ClassVar, Dict, List, Mapping, Optional, Tuple, Type, Union - -from .config import BaseConfig, Extra -from .fields import ModelField -from .main import BaseModel -from .typing import StrPath, display_as_type, get_origin, is_union -from .utils import deep_update, path_type, sequence_like - -env_file_sentinel = str(object()) - -SettingsSourceCallable = Callable[['BaseSettings'], Dict[str, Any]] -DotenvType = Union[StrPath, List[StrPath], Tuple[StrPath, ...]] - - -class SettingsError(ValueError): - pass - - -class BaseSettings(BaseModel): - """ - Base class for settings, allowing values to be overridden by environment variables. - - This is useful in production for secrets you do not wish to save in code, it plays nicely with docker(-compose), - Heroku and any 12 factor app design. - """ - - def __init__( - __pydantic_self__, - _env_file: Optional[DotenvType] = env_file_sentinel, - _env_file_encoding: Optional[str] = None, - _env_nested_delimiter: Optional[str] = None, - _secrets_dir: Optional[StrPath] = None, - **values: Any, - ) -> None: - # Uses something other than `self` the first arg to allow "self" as a settable attribute - super().__init__( - **__pydantic_self__._build_values( - values, - _env_file=_env_file, - _env_file_encoding=_env_file_encoding, - _env_nested_delimiter=_env_nested_delimiter, - _secrets_dir=_secrets_dir, - ) - ) - - def _build_values( - self, - init_kwargs: Dict[str, Any], - _env_file: Optional[DotenvType] = None, - _env_file_encoding: Optional[str] = None, - _env_nested_delimiter: Optional[str] = None, - _secrets_dir: Optional[StrPath] = None, - ) -> Dict[str, Any]: - # Configure built-in sources - init_settings = InitSettingsSource(init_kwargs=init_kwargs) - env_settings = EnvSettingsSource( - env_file=(_env_file if _env_file != env_file_sentinel else self.__config__.env_file), - env_file_encoding=( - _env_file_encoding if _env_file_encoding is not None else self.__config__.env_file_encoding - ), - env_nested_delimiter=( - _env_nested_delimiter if _env_nested_delimiter is not None else self.__config__.env_nested_delimiter - ), - env_prefix_len=len(self.__config__.env_prefix), - ) - file_secret_settings = SecretsSettingsSource(secrets_dir=_secrets_dir or self.__config__.secrets_dir) - # Provide a hook to set built-in sources priority and add / remove sources - sources = self.__config__.customise_sources( - init_settings=init_settings, env_settings=env_settings, file_secret_settings=file_secret_settings - ) - if sources: - return deep_update(*reversed([source(self) for source in sources])) - else: - # no one should mean to do this, but I think returning an empty dict is marginally preferable - # to an informative error and much better than a confusing error - return {} - - class Config(BaseConfig): - env_prefix: str = '' - env_file: Optional[DotenvType] = None - env_file_encoding: Optional[str] = None - env_nested_delimiter: Optional[str] = None - secrets_dir: Optional[StrPath] = None - validate_all: bool = True - extra: Extra = Extra.forbid - arbitrary_types_allowed: bool = True - case_sensitive: bool = False - - @classmethod - def prepare_field(cls, field: ModelField) -> None: - env_names: Union[List[str], AbstractSet[str]] - field_info_from_config = cls.get_field_info(field.name) - - env = field_info_from_config.get('env') or field.field_info.extra.get('env') - if env is None: - if field.has_alias: - warnings.warn( - 'aliases are no longer used by BaseSettings to define which environment variables to read. ' - 'Instead use the "env" field setting. ' - 'See https://pydantic-docs.helpmanual.io/usage/settings/#environment-variable-names', - FutureWarning, - ) - env_names = {cls.env_prefix + field.name} - elif isinstance(env, str): - env_names = {env} - elif isinstance(env, (set, frozenset)): - env_names = env - elif sequence_like(env): - env_names = list(env) - else: - raise TypeError(f'invalid field env: {env!r} ({display_as_type(env)}); should be string, list or set') - - if not cls.case_sensitive: - env_names = env_names.__class__(n.lower() for n in env_names) - field.field_info.extra['env_names'] = env_names - - @classmethod - def customise_sources( - cls, - init_settings: SettingsSourceCallable, - env_settings: SettingsSourceCallable, - file_secret_settings: SettingsSourceCallable, - ) -> Tuple[SettingsSourceCallable, ...]: - return init_settings, env_settings, file_secret_settings - - @classmethod - def parse_env_var(cls, field_name: str, raw_val: str) -> Any: - return cls.json_loads(raw_val) - - # populated by the metaclass using the Config class defined above, annotated here to help IDEs only - __config__: ClassVar[Type[Config]] - - -class InitSettingsSource: - __slots__ = ('init_kwargs',) - - def __init__(self, init_kwargs: Dict[str, Any]): - self.init_kwargs = init_kwargs - - def __call__(self, settings: BaseSettings) -> Dict[str, Any]: - return self.init_kwargs - - def __repr__(self) -> str: - return f'InitSettingsSource(init_kwargs={self.init_kwargs!r})' - - -class EnvSettingsSource: - __slots__ = ('env_file', 'env_file_encoding', 'env_nested_delimiter', 'env_prefix_len') - - def __init__( - self, - env_file: Optional[DotenvType], - env_file_encoding: Optional[str], - env_nested_delimiter: Optional[str] = None, - env_prefix_len: int = 0, - ): - self.env_file: Optional[DotenvType] = env_file - self.env_file_encoding: Optional[str] = env_file_encoding - self.env_nested_delimiter: Optional[str] = env_nested_delimiter - self.env_prefix_len: int = env_prefix_len - - def __call__(self, settings: BaseSettings) -> Dict[str, Any]: # noqa C901 - """ - Build environment variables suitable for passing to the Model. - """ - d: Dict[str, Any] = {} - - if settings.__config__.case_sensitive: - env_vars: Mapping[str, Optional[str]] = os.environ - else: - env_vars = {k.lower(): v for k, v in os.environ.items()} - - dotenv_vars = self._read_env_files(settings.__config__.case_sensitive) - if dotenv_vars: - env_vars = {**dotenv_vars, **env_vars} - - for field in settings.__fields__.values(): - env_val: Optional[str] = None - for env_name in field.field_info.extra['env_names']: - env_val = env_vars.get(env_name) - if env_val is not None: - break - - is_complex, allow_parse_failure = self.field_is_complex(field) - if is_complex: - if env_val is None: - # field is complex but no value found so far, try explode_env_vars - env_val_built = self.explode_env_vars(field, env_vars) - if env_val_built: - d[field.alias] = env_val_built - else: - # field is complex and there's a value, decode that as JSON, then add explode_env_vars - try: - env_val = settings.__config__.parse_env_var(field.name, env_val) - except ValueError as e: - if not allow_parse_failure: - raise SettingsError(f'error parsing env var "{env_name}"') from e - - if isinstance(env_val, dict): - d[field.alias] = deep_update(env_val, self.explode_env_vars(field, env_vars)) - else: - d[field.alias] = env_val - elif env_val is not None: - # simplest case, field is not complex, we only need to add the value if it was found - d[field.alias] = env_val - - return d - - def _read_env_files(self, case_sensitive: bool) -> Dict[str, Optional[str]]: - env_files = self.env_file - if env_files is None: - return {} - - if isinstance(env_files, (str, os.PathLike)): - env_files = [env_files] - - dotenv_vars = {} - for env_file in env_files: - env_path = Path(env_file).expanduser() - if env_path.is_file(): - dotenv_vars.update( - read_env_file(env_path, encoding=self.env_file_encoding, case_sensitive=case_sensitive) - ) - - return dotenv_vars - - def field_is_complex(self, field: ModelField) -> Tuple[bool, bool]: - """ - Find out if a field is complex, and if so whether JSON errors should be ignored - """ - if field.is_complex(): - allow_parse_failure = False - elif is_union(get_origin(field.type_)) and field.sub_fields and any(f.is_complex() for f in field.sub_fields): - allow_parse_failure = True - else: - return False, False - - return True, allow_parse_failure - - def explode_env_vars(self, field: ModelField, env_vars: Mapping[str, Optional[str]]) -> Dict[str, Any]: - """ - Process env_vars and extract the values of keys containing env_nested_delimiter into nested dictionaries. - - This is applied to a single field, hence filtering by env_var prefix. - """ - prefixes = [f'{env_name}{self.env_nested_delimiter}' for env_name in field.field_info.extra['env_names']] - result: Dict[str, Any] = {} - for env_name, env_val in env_vars.items(): - if not any(env_name.startswith(prefix) for prefix in prefixes): - continue - # we remove the prefix before splitting in case the prefix has characters in common with the delimiter - env_name_without_prefix = env_name[self.env_prefix_len :] - _, *keys, last_key = env_name_without_prefix.split(self.env_nested_delimiter) - env_var = result - for key in keys: - env_var = env_var.setdefault(key, {}) - env_var[last_key] = env_val - - return result - - def __repr__(self) -> str: - return ( - f'EnvSettingsSource(env_file={self.env_file!r}, env_file_encoding={self.env_file_encoding!r}, ' - f'env_nested_delimiter={self.env_nested_delimiter!r})' - ) - - -class SecretsSettingsSource: - __slots__ = ('secrets_dir',) - - def __init__(self, secrets_dir: Optional[StrPath]): - self.secrets_dir: Optional[StrPath] = secrets_dir - - def __call__(self, settings: BaseSettings) -> Dict[str, Any]: - """ - Build fields from "secrets" files. - """ - secrets: Dict[str, Optional[str]] = {} - - if self.secrets_dir is None: - return secrets - - secrets_path = Path(self.secrets_dir).expanduser() - - if not secrets_path.exists(): - warnings.warn(f'directory "{secrets_path}" does not exist') - return secrets - - if not secrets_path.is_dir(): - raise SettingsError(f'secrets_dir must reference a directory, not a {path_type(secrets_path)}') - - for field in settings.__fields__.values(): - for env_name in field.field_info.extra['env_names']: - path = find_case_path(secrets_path, env_name, settings.__config__.case_sensitive) - if not path: - # path does not exist, we curently don't return a warning for this - continue - - if path.is_file(): - secret_value = path.read_text().strip() - if field.is_complex(): - try: - secret_value = settings.__config__.parse_env_var(field.name, secret_value) - except ValueError as e: - raise SettingsError(f'error parsing env var "{env_name}"') from e - - secrets[field.alias] = secret_value - else: - warnings.warn( - f'attempted to load secret file "{path}" but found a {path_type(path)} instead.', - stacklevel=4, - ) - return secrets - - def __repr__(self) -> str: - return f'SecretsSettingsSource(secrets_dir={self.secrets_dir!r})' - - -def read_env_file( - file_path: StrPath, *, encoding: str = None, case_sensitive: bool = False -) -> Dict[str, Optional[str]]: - try: - from dotenv import dotenv_values - except ImportError as e: - raise ImportError('python-dotenv is not installed, run `pip install pydantic[dotenv]`') from e - - file_vars: Dict[str, Optional[str]] = dotenv_values(file_path, encoding=encoding or 'utf8') - if not case_sensitive: - return {k.lower(): v for k, v in file_vars.items()} - else: - return file_vars - - -def find_case_path(dir_path: Path, file_name: str, case_sensitive: bool) -> Optional[Path]: - """ - Find a file within path's directory matching filename, optionally ignoring case. - """ - for f in dir_path.iterdir(): - if f.name == file_name: - return f - elif not case_sensitive and f.name.lower() == file_name.lower(): - return f - return None diff --git a/pydantic/fields.py b/pydantic/fields.py index cecd3d2..ef047eb 100644 --- a/pydantic/fields.py +++ b/pydantic/fields.py @@ -1150,18 +1150,6 @@ class ModelField(Representation): return v, ErrorWrapper(exc, loc) return v, None - def is_complex(self) -> bool: - """ - Whether the field is "complex" eg. env variables should be parsed as JSON. - """ - from .main import BaseModel - - return ( - self.shape != SHAPE_SINGLETON - or hasattr(self.type_, '__pydantic_model__') - or lenient_issubclass(self.type_, (BaseModel, list, set, frozenset, dict)) - ) - def _type_display(self) -> PyObjectStr: t = display_as_type(self.type_) diff --git a/pydantic/mypy.py b/pydantic/mypy.py index 6b8c044..ffa27d2 100644 --- a/pydantic/mypy.py +++ b/pydantic/mypy.py @@ -74,7 +74,6 @@ except ImportError: # pragma: no cover CONFIGFILE_KEY = 'pydantic-mypy' METADATA_KEY = 'pydantic-mypy-metadata' BASEMODEL_FULLNAME = 'pydantic.main.BaseModel' -BASESETTINGS_FULLNAME = 'pydantic.env_settings.BaseSettings' FIELD_FULLNAME = 'pydantic.fields.Field' DATACLASS_FULLNAME = 'pydantic.dataclasses.dataclass' @@ -263,8 +262,7 @@ class PydanticModelTransformer: if info[field.name].type is None: if not ctx.api.final_iteration: ctx.api.defer() - is_settings = any(get_fullname(base) == BASESETTINGS_FULLNAME for base in info.mro[:-1]) - self.add_initializer(fields, config, is_settings) + self.add_initializer(fields, config) self.add_construct_method(fields) self.set_frozen(fields, frozen=config.allow_mutation is False or config.frozen is True) info.metadata[METADATA_KEY] = { @@ -405,7 +403,7 @@ class PydanticModelTransformer: all_fields = superclass_fields + all_fields return all_fields - def add_initializer(self, fields: List['PydanticModelField'], config: 'ModelConfigData', is_settings: bool) -> None: + def add_initializer(self, fields: List['PydanticModelField'], config: 'ModelConfigData') -> None: """ Adds a fields-aware `__init__` method to the class. @@ -414,9 +412,7 @@ class PydanticModelTransformer: ctx = self._ctx typed = self.plugin_config.init_typed use_alias = config.allow_population_by_field_name is not True - force_all_optional = is_settings or bool( - config.has_alias_generator and not config.allow_population_by_field_name - ) + force_all_optional = bool(config.has_alias_generator and not config.allow_population_by_field_name) init_arguments = self.get_field_arguments( fields, typed=typed, force_all_optional=force_all_optional, use_alias=use_alias ) diff --git a/pydantic/version.py b/pydantic/version.py index 8fc811d..16b0d2e 100644 --- a/pydantic/version.py +++ b/pydantic/version.py @@ -10,7 +10,7 @@ def version_info() -> str: from pathlib import Path optional_deps = [] - for p in ('devtools', 'dotenv', 'email-validator', 'typing-extensions'): + for p in 'devtools', 'email-validator', 'typing-extensions': try: import_module(p.replace('-', '_')) except ImportError: diff --git a/pyproject.toml b/pyproject.toml index 5e3a511..694e320 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ classifiers = [ ] requires-python = '>=3.7' dependencies = [ 'typing-extensions>=4.1.0' ] -optional-dependencies = { email = ['email-validator>=1.0.3'], dotenv = ['python-dotenv>=0.10.4'] } +optional-dependencies = { email = ['email-validator>=1.0.3'] } dynamic = ['version'] entry-points.hypothesis = {_ = 'pydantic._hypothesis_plugin'} @@ -135,7 +135,6 @@ disallow_untyped_defs = true [[tool.mypy.overrides]] module = [ 'email_validator.*', - 'dotenv.*', 'toml.*', 'ansi2html.*', 'devtools.*', diff --git a/requirements/pyproject-all.txt b/requirements/pyproject-all.txt index 5e35901..5ca4276 100644 --- a/requirements/pyproject-all.txt +++ b/requirements/pyproject-all.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with python 3.10 # To update, run: # -# pip-compile --extra=email,dotenv --output-file=requirements/pyproject-all.txt pyproject.toml +# pip-compile --extra=email --output-file=requirements/pyproject-all.txt pyproject.toml # dnspython==2.2.1 # via email-validator @@ -10,7 +10,5 @@ email-validator==1.2.1 # via pydantic (pyproject.toml) idna==3.3 # via email-validator -python-dotenv==0.20.0 - # via pydantic (pyproject.toml) typing-extensions==4.3.0 # via pydantic (pyproject.toml) diff --git a/tests/conftest.py b/tests/conftest.py index 0d1c6c4..594a23d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ import importlib import inspect -import os import secrets import sys import textwrap @@ -33,28 +32,6 @@ def _create_module_file(code, tmp_path, name): return name, str(path) -class SetEnv: - def __init__(self): - self.envars = set() - - def set(self, name, value): - self.envars.add(name) - os.environ[name] = value - - def clear(self): - for n in self.envars: - os.environ.pop(n) - - -@pytest.fixture -def env(): - setenv = SetEnv() - - yield setenv - - setenv.clear() - - @pytest.fixture def create_module(tmp_path, request): def run(source_code_or_function, rewrite_assertions=True): diff --git a/tests/mypy/modules/plugin_fail.py b/tests/mypy/modules/plugin_fail.py index 91fcbae..a2528a1 100644 --- a/tests/mypy/modules/plugin_fail.py +++ b/tests/mypy/modules/plugin_fail.py @@ -1,6 +1,6 @@ from typing import Any, Generic, List, Optional, Set, TypeVar, Union -from pydantic import BaseModel, BaseSettings, Extra, Field, validator +from pydantic import BaseModel, Extra, Field, validator from pydantic.dataclasses import dataclass from pydantic.generics import GenericModel @@ -94,19 +94,12 @@ class UndefinedAnnotationModel(BaseModel): UndefinedAnnotationModel() -class Settings(BaseSettings): - x: int - - Model.construct(x=1) Model.construct(_fields_set={'x'}, x=1, y='2') Model.construct(x='1', y='2') -Settings() # should pass here due to possibly reading from environment - # Strict mode fails inheriting = InheritingModel(x='1', y='1') -Settings(x='1') Model(x='1', y='2') diff --git a/tests/mypy/modules/plugin_success.py b/tests/mypy/modules/plugin_success.py index 1d3054b..69a1ffc 100644 --- a/tests/mypy/modules/plugin_success.py +++ b/tests/mypy/modules/plugin_success.py @@ -1,6 +1,6 @@ from typing import ClassVar, Generic, List, Optional, TypeVar, Union -from pydantic import BaseModel, BaseSettings, Field, create_model, validator +from pydantic import BaseModel, Field, create_model, validator from pydantic.dataclasses import dataclass from pydantic.generics import GenericModel @@ -165,13 +165,6 @@ class ModelWithSelfField(BaseModel): self: str -class SettingsModel(BaseSettings): - pass - - -settings = SettingsModel.construct() - - def f(name: str) -> str: return name diff --git a/tests/mypy/modules/settings_config.py b/tests/mypy/modules/settings_config.py deleted file mode 100644 index 41c767a..0000000 --- a/tests/mypy/modules/settings_config.py +++ /dev/null @@ -1,7 +0,0 @@ -from pydantic import BaseSettings - - -class Settings(BaseSettings): - class Config(BaseSettings.Config): - env_file = '.env' - env_file_encoding = 'utf-8' diff --git a/tests/mypy/modules/success.py b/tests/mypy/modules/success.py index e7241c8..9f02540 100644 --- a/tests/mypy/modules/success.py +++ b/tests/mypy/modules/success.py @@ -16,7 +16,6 @@ from pydantic import ( UUID1, BaseConfig, BaseModel, - BaseSettings, DirectoryPath, Extra, FilePath, @@ -273,10 +272,6 @@ class Config(BaseConfig): max_anystr_length = 1234 -class Settings(BaseSettings): - ... - - class CustomPath(PurePath): def __init__(self, *args: str): self.path = os.path.join(*args) @@ -285,10 +280,5 @@ class CustomPath(PurePath): return f'a/custom/{self.path}' -def dont_check_path_existence() -> None: - Settings(_env_file='a/path', _secrets_dir='a/path') - Settings(_env_file=CustomPath('a/path'), _secrets_dir=CustomPath('a/path')) - - create_model_from_typeddict(SomeDict)(**obj) DynamicModel = create_model('DynamicModel') diff --git a/tests/mypy/outputs/plugin-fail-strict.txt b/tests/mypy/outputs/plugin-fail-strict.txt index 05d8873..3e3634b 100644 --- a/tests/mypy/outputs/plugin-fail-strict.txt +++ b/tests/mypy/outputs/plugin-fail-strict.txt @@ -18,26 +18,25 @@ 87: error: Missing named argument "e" for "DefaultTestingModel" [call-arg] 91: error: Name "Undefined" is not defined [name-defined] 94: error: Missing named argument "undefined" for "UndefinedAnnotationModel" [call-arg] -101: error: Missing named argument "y" for "construct" of "Model" [call-arg] -103: error: Argument "x" to "construct" of "Model" has incompatible type "str"; expected "int" [arg-type] -108: error: Argument "x" to "InheritingModel" has incompatible type "str"; expected "int" [arg-type] -109: error: Argument "x" to "Settings" has incompatible type "str"; expected "int" [arg-type] -110: error: Argument "x" to "Model" has incompatible type "str"; expected "int" [arg-type] -127: error: Argument "data" to "Response" has incompatible type "int"; expected "Model" [arg-type] -135: error: Argument "y" to "AliasModel" has incompatible type "int"; expected "str" [arg-type] -141: error: Required dynamic aliases disallowed [pydantic-alias] -145: error: Argument "z" to "DynamicAliasModel" has incompatible type "str"; expected "int" [arg-type] -156: error: Unexpected keyword argument "y" for "DynamicAliasModel2" [call-arg] -163: error: Required dynamic aliases disallowed [pydantic-alias] -181: error: Untyped fields disallowed [pydantic-field] -185: error: Unexpected keyword argument "x" for "AliasGeneratorModel2" [call-arg] -186: error: Unexpected keyword argument "z" for "AliasGeneratorModel2" [call-arg] -189: error: Name "Missing" is not defined [name-defined] -219: error: Property "y" defined in "FrozenModel" is read-only [misc] -240: error: Incompatible types in assignment (expression has type "None", variable has type "int") [assignment] -241: error: Incompatible types in assignment (expression has type "None", variable has type "int") [assignment] -244: error: Incompatible types in assignment (expression has type "Set[Any]", variable has type "str") [assignment] -245: error: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment] -247: error: Argument "default_factory" to "Field" has incompatible type "int"; expected "Optional[Callable[[], Any]]" [arg-type] -250: error: Field default and default_factory cannot be specified together [pydantic-field] -260: error: Missing positional argument "self" in call to "instance_method" of "ModelWithAnnotatedValidator" [call-arg] +97: error: Missing named argument "y" for "construct" of "Model" [call-arg] +99: error: Argument "x" to "construct" of "Model" has incompatible type "str"; expected "int" [arg-type] +102: error: Argument "x" to "InheritingModel" has incompatible type "str"; expected "int" [arg-type] +103: error: Argument "x" to "Model" has incompatible type "str"; expected "int" [arg-type] +120: error: Argument "data" to "Response" has incompatible type "int"; expected "Model" [arg-type] +128: error: Argument "y" to "AliasModel" has incompatible type "int"; expected "str" [arg-type] +134: error: Required dynamic aliases disallowed [pydantic-alias] +138: error: Argument "z" to "DynamicAliasModel" has incompatible type "str"; expected "int" [arg-type] +149: error: Unexpected keyword argument "y" for "DynamicAliasModel2" [call-arg] +156: error: Required dynamic aliases disallowed [pydantic-alias] +174: error: Untyped fields disallowed [pydantic-field] +178: error: Unexpected keyword argument "x" for "AliasGeneratorModel2" [call-arg] +179: error: Unexpected keyword argument "z" for "AliasGeneratorModel2" [call-arg] +182: error: Name "Missing" is not defined [name-defined] +212: error: Property "y" defined in "FrozenModel" is read-only [misc] +233: error: Incompatible types in assignment (expression has type "None", variable has type "int") [assignment] +234: error: Incompatible types in assignment (expression has type "None", variable has type "int") [assignment] +237: error: Incompatible types in assignment (expression has type "Set[Any]", variable has type "str") [assignment] +238: error: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment] +240: error: Argument "default_factory" to "Field" has incompatible type "int"; expected "Optional[Callable[[], Any]]" [arg-type] +243: error: Field default and default_factory cannot be specified together [pydantic-field] +253: error: Missing positional argument "self" in call to "instance_method" of "ModelWithAnnotatedValidator" [call-arg] diff --git a/tests/mypy/outputs/plugin-fail.txt b/tests/mypy/outputs/plugin-fail.txt index 3630284..3e21769 100644 --- a/tests/mypy/outputs/plugin-fail.txt +++ b/tests/mypy/outputs/plugin-fail.txt @@ -16,17 +16,17 @@ 87: error: Missing named argument "e" for "DefaultTestingModel" [call-arg] 91: error: Name "Undefined" is not defined [name-defined] 94: error: Missing named argument "undefined" for "UndefinedAnnotationModel" [call-arg] -101: error: Missing named argument "y" for "construct" of "Model" [call-arg] -103: error: Argument "x" to "construct" of "Model" has incompatible type "str"; expected "int" [arg-type] -156: error: Missing named argument "x" for "DynamicAliasModel2" [call-arg] +97: error: Missing named argument "y" for "construct" of "Model" [call-arg] +99: error: Argument "x" to "construct" of "Model" has incompatible type "str"; expected "int" [arg-type] +149: error: Missing named argument "x" for "DynamicAliasModel2" [call-arg] +168: error: Unused "type: ignore" comment 175: error: Unused "type: ignore" comment -182: error: Unused "type: ignore" comment -189: error: Name "Missing" is not defined [name-defined] -219: error: Property "y" defined in "FrozenModel" is read-only [misc] -240: error: Incompatible types in assignment (expression has type "None", variable has type "int") [assignment] -241: error: Incompatible types in assignment (expression has type "None", variable has type "int") [assignment] -244: error: Incompatible types in assignment (expression has type "Set[Any]", variable has type "str") [assignment] -245: error: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment] -247: error: Argument "default_factory" to "Field" has incompatible type "int"; expected "Optional[Callable[[], Any]]" [arg-type] -250: error: Field default and default_factory cannot be specified together [pydantic-field] -260: error: Missing positional argument "self" in call to "instance_method" of "ModelWithAnnotatedValidator" [call-arg] +182: error: Name "Missing" is not defined [name-defined] +212: error: Property "y" defined in "FrozenModel" is read-only [misc] +233: error: Incompatible types in assignment (expression has type "None", variable has type "int") [assignment] +234: error: Incompatible types in assignment (expression has type "None", variable has type "int") [assignment] +237: error: Incompatible types in assignment (expression has type "Set[Any]", variable has type "str") [assignment] +238: error: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment] +240: error: Argument "default_factory" to "Field" has incompatible type "int"; expected "Optional[Callable[[], Any]]" [arg-type] +243: error: Field default and default_factory cannot be specified together [pydantic-field] +253: error: Missing positional argument "self" in call to "instance_method" of "ModelWithAnnotatedValidator" [call-arg] diff --git a/tests/mypy/test_mypy.py b/tests/mypy/test_mypy.py index d12c873..a694b07 100644 --- a/tests/mypy/test_mypy.py +++ b/tests/mypy/test_mypy.py @@ -46,7 +46,6 @@ cases = [ ('pyproject-plugin-strict.toml', 'plugin_success.py', 'plugin-success-strict.txt'), ('pyproject-plugin-strict.toml', 'plugin_fail.py', 'plugin-fail-strict.txt'), ('pyproject-plugin-strict.toml', 'fail_defaults.py', 'fail_defaults.txt'), - ('mypy-plugin-strict.ini', 'settings_config.py', None), ('mypy-plugin-strict.ini', 'plugin_default_factory.py', 'plugin_default_factory.txt'), ] executable_modules = list({fname[:-3] for _, fname, out_fname in cases if out_fname is None}) diff --git a/tests/pyright/pyright_example.py b/tests/pyright/pyright_example.py index 0819afc..bd286b9 100644 --- a/tests/pyright/pyright_example.py +++ b/tests/pyright/pyright_example.py @@ -1,13 +1,10 @@ """ This file is used to test pyright's ability to check pydantic code. - -In particular pydantic provides the `@__dataclass_transform__` for `BaseModel` -and all subclasses (including `BaseSettings`), see #2721. """ from typing import List -from pydantic import BaseModel, BaseSettings, Field +from pydantic import BaseModel, Field class MyModel(BaseModel): @@ -26,13 +23,3 @@ class Knight(BaseModel): k = Knight() # pyright: ignore - - -class Settings(BaseSettings): - x: str - y: int - - -s1 = Settings.parse_obj({}) - -s2 = Settings() # pyright: ignore[reportGeneralTypeIssues] diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index 8c5e8c3..4533641 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -9,7 +9,6 @@ import pytest from pydantic import ( BaseModel, - BaseSettings, Extra, NoneStrBytes, StrBytes, @@ -1207,21 +1206,19 @@ def test_self(): } -@pytest.mark.parametrize('model', [BaseModel, BaseSettings]) -def test_self_recursive(model): - class SubModel(model): +def test_self_recursive(): + class SubModel(BaseModel): self: int - class Model(model): + class Model(BaseModel): sm: SubModel m = Model.parse_obj({'sm': {'self': '123'}}) assert m.dict() == {'sm': {'self': 123}} -@pytest.mark.parametrize('model', [BaseModel, BaseSettings]) -def test_nested_init(model): - class NestedModel(model): +def test_nested_init(): + class NestedModel(BaseModel): self: str modified_number: int = 1 @@ -1229,7 +1226,7 @@ def test_nested_init(model): super().__init__(**kwargs) someinit.modified_number += 1 - class TopModel(model): + class TopModel(BaseModel): self: str nest: NestedModel diff --git a/tests/test_settings.py b/tests/test_settings.py deleted file mode 100644 index d61e620..0000000 --- a/tests/test_settings.py +++ /dev/null @@ -1,1280 +0,0 @@ -import os -import sys -import uuid -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union - -import pytest - -from pydantic import BaseModel, BaseSettings, Field, HttpUrl, NoneStr, SecretStr, ValidationError, dataclasses -from pydantic.env_settings import ( - EnvSettingsSource, - InitSettingsSource, - SecretsSettingsSource, - SettingsError, - SettingsSourceCallable, - read_env_file, -) - -try: - import dotenv -except ImportError: - dotenv = None - - -class SimpleSettings(BaseSettings): - apple: str - - -def test_sub_env(env): - env.set('apple', 'hello') - s = SimpleSettings() - assert s.apple == 'hello' - - -def test_sub_env_override(env): - env.set('apple', 'hello') - s = SimpleSettings(apple='goodbye') - assert s.apple == 'goodbye' - - -def test_sub_env_missing(): - with pytest.raises(ValidationError) as exc_info: - SimpleSettings() - assert exc_info.value.errors() == [{'loc': ('apple',), 'msg': 'field required', 'type': 'value_error.missing'}] - - -def test_other_setting(): - with pytest.raises(ValidationError): - SimpleSettings(apple='a', foobar=42) - - -def test_with_prefix(env): - class Settings(BaseSettings): - apple: str - - class Config: - env_prefix = 'foobar_' - - with pytest.raises(ValidationError): - Settings() - env.set('foobar_apple', 'has_prefix') - s = Settings() - assert s.apple == 'has_prefix' - - -def test_nested_env_with_basemodel(env): - class TopValue(BaseModel): - apple: str - banana: str - - class Settings(BaseSettings): - top: TopValue - - with pytest.raises(ValidationError): - Settings() - env.set('top', '{"banana": "secret_value"}') - s = Settings(top={'apple': 'value'}) - assert s.top == {'apple': 'value', 'banana': 'secret_value'} - - -def test_merge_dict(env): - class Settings(BaseSettings): - top: Dict[str, str] - - with pytest.raises(ValidationError): - Settings() - env.set('top', '{"banana": "secret_value"}') - s = Settings(top={'apple': 'value'}) - assert s.top == {'apple': 'value', 'banana': 'secret_value'} - - -def test_nested_env_delimiter(env): - class SubSubValue(BaseSettings): - v6: str - - class SubValue(BaseSettings): - v4: str - v5: int - sub_sub: SubSubValue - - class TopValue(BaseSettings): - v1: str - v2: str - v3: str - sub: SubValue - - class Cfg(BaseSettings): - v0: str - v0_union: Union[SubValue, int] - top: TopValue - - class Config: - env_nested_delimiter = '__' - - env.set('top', '{"v1": "json-1", "v2": "json-2", "sub": {"v5": "xx"}}') - env.set('top__sub__v5', '5') - env.set('v0', '0') - env.set('top__v2', '2') - env.set('top__v3', '3') - env.set('v0_union', '0') - env.set('top__sub__sub_sub__v6', '6') - env.set('top__sub__v4', '4') - cfg = Cfg() - assert cfg.dict() == { - 'v0': '0', - 'v0_union': 0, - 'top': { - 'v1': 'json-1', - 'v2': '2', - 'v3': '3', - 'sub': {'v4': '4', 'v5': 5, 'sub_sub': {'v6': '6'}}, - }, - } - - -def test_nested_env_delimiter_with_prefix(env): - class Subsettings(BaseSettings): - banana: str - - class Settings(BaseSettings): - subsettings: Subsettings - - class Config: - env_nested_delimiter = '_' - env_prefix = 'myprefix_' - - env.set('myprefix_subsettings_banana', 'banana') - s = Settings() - assert s.subsettings.banana == 'banana' - - class Settings(BaseSettings): - subsettings: Subsettings - - class Config: - env_nested_delimiter = '_' - env_prefix = 'myprefix__' - - env.set('myprefix__subsettings_banana', 'banana') - s = Settings() - assert s.subsettings.banana == 'banana' - - -def test_nested_env_delimiter_complex_required(env): - class Cfg(BaseSettings): - v: str = 'default' - - class Config: - env_nested_delimiter = '__' - - env.set('v__x', 'x') - env.set('v__y', 'y') - cfg = Cfg() - assert cfg.dict() == {'v': 'default'} - - -def test_nested_env_delimiter_aliases(env): - class SubModel(BaseSettings): - v1: str - v2: str - - class Cfg(BaseSettings): - sub_model: SubModel - - class Config: - fields = {'sub_model': {'env': ['foo', 'bar']}} - env_nested_delimiter = '__' - - env.set('foo__v1', '-1-') - env.set('bar__v2', '-2-') - assert Cfg().dict() == {'sub_model': {'v1': '-1-', 'v2': '-2-'}} - - -class DateModel(BaseModel): - pips: bool = False - - -class ComplexSettings(BaseSettings): - apples: List[str] = [] - bananas: Set[int] = set() - carrots: dict = {} - date: DateModel = DateModel() - - -def test_list(env): - env.set('apples', '["russet", "granny smith"]') - s = ComplexSettings() - assert s.apples == ['russet', 'granny smith'] - assert s.date.pips is False - - -def test_set_dict_model(env): - env.set('bananas', '[1, 2, 3, 3]') - env.set('CARROTS', '{"a": null, "b": 4}') - env.set('daTE', '{"pips": true}') - s = ComplexSettings() - assert s.bananas == {1, 2, 3} - assert s.carrots == {'a': None, 'b': 4} - assert s.date.pips is True - - -def test_invalid_json(env): - env.set('apples', '["russet", "granny smith",]') - with pytest.raises(SettingsError, match='error parsing env var "apples"'): - ComplexSettings() - - -def test_required_sub_model(env): - class Settings(BaseSettings): - foobar: DateModel - - with pytest.raises(ValidationError): - Settings() - env.set('FOOBAR', '{"pips": "TRUE"}') - s = Settings() - assert s.foobar.pips is True - - -def test_non_class(env): - class Settings(BaseSettings): - foobar: NoneStr - - env.set('FOOBAR', 'xxx') - s = Settings() - assert s.foobar == 'xxx' - - -def test_env_str(env): - class Settings(BaseSettings): - apple: str = ... - - class Config: - fields = {'apple': {'env': 'BOOM'}} - - env.set('BOOM', 'hello') - assert Settings().apple == 'hello' - - -def test_env_list(env): - class Settings(BaseSettings): - foobar: str - - class Config: - fields = {'foobar': {'env': ['different1', 'different2']}} - - env.set('different1', 'value 1') - env.set('different2', 'value 2') - s = Settings() - assert s.foobar == 'value 1' - - -def test_env_list_field(env): - class Settings(BaseSettings): - foobar: str = Field(..., env='foobar_env_name') - - env.set('FOOBAR_ENV_NAME', 'env value') - s = Settings() - assert s.foobar == 'env value' - - -def test_env_list_last(env): - class Settings(BaseSettings): - foobar: str - - class Config: - fields = {'foobar': {'env': ['different2']}} - - env.set('different1', 'value 1') - env.set('different2', 'value 2') - s = Settings() - assert s.foobar == 'value 2' - assert Settings(foobar='abc').foobar == 'abc' - - -def test_env_inheritance(env): - class SettingsParent(BaseSettings): - foobar: str = 'parent default' - - class Config: - fields = {'foobar': {'env': 'different'}} - - class SettingsChild(SettingsParent): - foobar: str = 'child default' - - assert SettingsParent().foobar == 'parent default' - assert SettingsParent(foobar='abc').foobar == 'abc' - - assert SettingsChild().foobar == 'child default' - assert SettingsChild(foobar='abc').foobar == 'abc' - env.set('different', 'env value') - assert SettingsParent().foobar == 'env value' - assert SettingsParent(foobar='abc').foobar == 'abc' - assert SettingsChild().foobar == 'env value' - assert SettingsChild(foobar='abc').foobar == 'abc' - - -def test_env_inheritance_field(env): - class SettingsParent(BaseSettings): - foobar: str = Field('parent default', env='foobar_env') - - class SettingsChild(SettingsParent): - foobar: str = 'child default' - - assert SettingsParent().foobar == 'parent default' - assert SettingsParent(foobar='abc').foobar == 'abc' - - assert SettingsChild().foobar == 'child default' - assert SettingsChild(foobar='abc').foobar == 'abc' - env.set('foobar_env', 'env value') - assert SettingsParent().foobar == 'env value' - assert SettingsParent(foobar='abc').foobar == 'abc' - assert SettingsChild().foobar == 'child default' - assert SettingsChild(foobar='abc').foobar == 'abc' - - -def test_env_prefix_inheritance_config(env): - env.set('foobar', 'foobar') - env.set('prefix_foobar', 'prefix_foobar') - - env.set('foobar_parent_from_field', 'foobar_parent_from_field') - env.set('foobar_child_from_field', 'foobar_child_from_field') - - env.set('foobar_parent_from_config', 'foobar_parent_from_config') - env.set('foobar_child_from_config', 'foobar_child_from_config') - - # . Child prefix does not override explicit parent field config - class Parent(BaseSettings): - foobar: str = Field(None, env='foobar_parent_from_field') - - class Child(Parent): - class Config: - env_prefix = 'prefix_' - - assert Child().foobar == 'foobar_parent_from_field' - - # c. Child prefix does not override explicit parent class config - class Parent(BaseSettings): - foobar: str = None - - class Config: - fields = { - 'foobar': {'env': ['foobar_parent_from_config']}, - } - - class Child(Parent): - class Config: - env_prefix = 'prefix_' - - assert Child().foobar == 'foobar_parent_from_config' - - # d. Child prefix overrides parent with implicit config - class Parent(BaseSettings): - foobar: str = None - - class Child(Parent): - class Config: - env_prefix = 'prefix_' - - assert Child().foobar == 'prefix_foobar' - - -def test_env_inheritance_config(env): - env.set('foobar', 'foobar') - env.set('prefix_foobar', 'prefix_foobar') - - env.set('foobar_parent_from_field', 'foobar_parent_from_field') - env.set('foobar_child_from_field', 'foobar_child_from_field') - - env.set('foobar_parent_from_config', 'foobar_parent_from_config') - env.set('foobar_child_from_config', 'foobar_child_from_config') - - # a. Child class config overrides prefix and parent field config - class Parent(BaseSettings): - foobar: str = Field(None, env='foobar_parent_from_field') - - class Child(Parent): - class Config: - env_prefix = 'prefix_' - fields = { - 'foobar': {'env': ['foobar_child_from_config']}, - } - - assert Child().foobar == 'foobar_child_from_config' - - # b. Child class config overrides prefix and parent class config - class Parent(BaseSettings): - foobar: str = None - - class Config: - fields = { - 'foobar': {'env': ['foobar_parent_from_config']}, - } - - class Child(Parent): - class Config: - env_prefix = 'prefix_' - fields = { - 'foobar': {'env': ['foobar_child_from_config']}, - } - - assert Child().foobar == 'foobar_child_from_config' - - # . Child class config overrides prefix and parent with implicit config - class Parent(BaseSettings): - foobar: Optional[str] - - class Child(Parent): - class Config: - env_prefix = 'prefix_' - fields = { - 'foobar': {'env': ['foobar_child_from_field']}, - } - - assert Child().foobar == 'foobar_child_from_field' - - -def test_env_invalid(env): - with pytest.raises(TypeError, match=r'invalid field env: 123 \(int\); should be string, list or set'): - - class Settings(BaseSettings): - foobar: str - - class Config: - fields = {'foobar': {'env': 123}} - - -def test_env_field(env): - with pytest.raises(TypeError, match=r'invalid field env: 123 \(int\); should be string, list or set'): - - class Settings(BaseSettings): - foobar: str = Field(..., env=123) - - -def test_aliases_warning(env): - with pytest.warns(FutureWarning, match='aliases are no longer used by BaseSettings'): - - class Settings(BaseSettings): - foobar: str = 'default value' - - class Config: - fields = {'foobar': 'foobar_alias'} - - assert Settings().foobar == 'default value' - env.set('foobar_alias', 'xxx') - assert Settings().foobar == 'default value' - assert Settings(foobar_alias='42').foobar == '42' - - -def test_aliases_no_warning(env): - class Settings(BaseSettings): - foobar: str = 'default value' - - class Config: - fields = {'foobar': {'alias': 'foobar_alias', 'env': 'foobar_env'}} - - assert Settings().foobar == 'default value' - assert Settings(foobar_alias='42').foobar == '42' - env.set('foobar_alias', 'xxx') - assert Settings().foobar == 'default value' - env.set('foobar_env', 'xxx') - assert Settings().foobar == 'xxx' - assert Settings(foobar_alias='42').foobar == '42' - - -def test_case_sensitive(monkeypatch): - class Settings(BaseSettings): - foo: str - - class Config: - case_sensitive = True - - # Need to patch os.environ to get build to work on Windows, where os.environ is case insensitive - monkeypatch.setattr(os, 'environ', value={'Foo': 'foo'}) - with pytest.raises(ValidationError) as exc_info: - Settings() - assert exc_info.value.errors() == [{'loc': ('foo',), 'msg': 'field required', 'type': 'value_error.missing'}] - - -def test_nested_dataclass(env): - @dataclasses.dataclass - class MyDataclass: - foo: int - bar: str - - class Settings(BaseSettings): - n: MyDataclass - - env.set('N', '[123, "bar value"]') - s = Settings() - assert isinstance(s.n, MyDataclass) - assert s.n.foo == 123 - assert s.n.bar == 'bar value' - - -def test_env_takes_precedence(env): - class Settings(BaseSettings): - foo: int - bar: str - - class Config: - @classmethod - def customise_sources( - cls, - init_settings: SettingsSourceCallable, - env_settings: SettingsSourceCallable, - file_secret_settings: SettingsSourceCallable, - ) -> Tuple[SettingsSourceCallable, ...]: - return env_settings, init_settings - - env.set('BAR', 'env setting') - - s = Settings(foo='123', bar='argument') - assert s.foo == 123 - assert s.bar == 'env setting' - - -def test_config_file_settings_nornir(env): - """ - See https://github.com/pydantic/pydantic/pull/341#issuecomment-450378771 - """ - - def nornir_settings_source(settings: BaseSettings) -> Dict[str, Any]: - return {'param_a': 'config a', 'param_b': 'config b', 'param_c': 'config c'} - - class Settings(BaseSettings): - param_a: str - param_b: str - param_c: str - - class Config: - @classmethod - def customise_sources( - cls, - init_settings: SettingsSourceCallable, - env_settings: SettingsSourceCallable, - file_secret_settings: SettingsSourceCallable, - ) -> Tuple[SettingsSourceCallable, ...]: - return env_settings, init_settings, nornir_settings_source - - env.set('PARAM_C', 'env setting c') - - s = Settings(param_b='argument b', param_c='argument c') - assert s.param_a == 'config a' - assert s.param_b == 'argument b' - assert s.param_c == 'env setting c' - - -def test_env_union_with_complex_subfields_parses_json(env): - class A(BaseSettings): - a: str - - class B(BaseSettings): - b: int - - class Settings(BaseSettings): - content: Union[A, B, int] - - env.set('content', '{"a": "test"}') - s = Settings() - assert s.content == A(a='test') - - -def test_env_union_with_complex_subfields_parses_plain_if_json_fails(env): - class A(BaseSettings): - a: str - - class B(BaseSettings): - b: int - - class Settings(BaseSettings): - content: Union[A, B, datetime] - - env.set('content', '2020-07-05T00:00:00Z') - s = Settings() - assert s.content == datetime(2020, 7, 5, 0, 0, tzinfo=timezone.utc) - - -def test_env_union_without_complex_subfields_does_not_parse_json(env): - class Settings(BaseSettings): - content: Union[datetime, str] - - env.set('content', '2020-07-05T00:00:00Z') - s = Settings() - assert s.content == datetime(2020, 7, 5, 0, 0, tzinfo=timezone.utc) - - -test_env_file = """\ -# this is a comment -A=good string -# another one, followed by whitespace - -b='better string' -c="best string" -""" - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_env_file_config(env, tmp_path): - p = tmp_path / '.env' - p.write_text(test_env_file) - - class Settings(BaseSettings): - a: str - b: str - c: str - - class Config: - env_file = p - - env.set('A', 'overridden var') - - s = Settings() - assert s.a == 'overridden var' - assert s.b == 'better string' - assert s.c == 'best string' - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_env_file_config_case_sensitive(tmp_path): - p = tmp_path / '.env' - p.write_text(test_env_file) - - class Settings(BaseSettings): - a: str - b: str - c: str - - class Config: - env_file = p - case_sensitive = True - - with pytest.raises(ValidationError) as exc_info: - Settings() - assert exc_info.value.errors() == [{'loc': ('a',), 'msg': 'field required', 'type': 'value_error.missing'}] - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_env_file_export(env, tmp_path): - p = tmp_path / '.env' - p.write_text( - """\ -export A='good string' -export B=better-string -export C="best string" -""" - ) - - class Settings(BaseSettings): - a: str - b: str - c: str - - class Config: - env_file = p - - env.set('A', 'overridden var') - - s = Settings() - assert s.a == 'overridden var' - assert s.b == 'better-string' - assert s.c == 'best string' - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_env_file_config_custom_encoding(tmp_path): - p = tmp_path / '.env' - p.write_text('pika=p!±@', encoding='latin-1') - - class Settings(BaseSettings): - pika: str - - class Config: - env_file = p - env_file_encoding = 'latin-1' - - s = Settings() - assert s.pika == 'p!±@' - - -@pytest.fixture -def home_tmp(): - tmp_filename = f'{uuid.uuid4()}.env' - home_tmp_path = Path.home() / tmp_filename - yield home_tmp_path, tmp_filename - home_tmp_path.unlink() - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_env_file_home_directory(home_tmp): - home_tmp_path, tmp_filename = home_tmp - home_tmp_path.write_text('pika=baz') - - class Settings(BaseSettings): - pika: str - - class Config: - env_file = f'~/{tmp_filename}' - - assert Settings().pika == 'baz' - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_env_file_none(tmp_path): - p = tmp_path / '.env' - p.write_text('a') - - class Settings(BaseSettings): - a: str = 'xxx' - - s = Settings(_env_file=p) - assert s.a == 'xxx' - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_env_file_override_file(tmp_path): - p1 = tmp_path / '.env' - p1.write_text(test_env_file) - p2 = tmp_path / '.env.prod' - p2.write_text('A="new string"') - - class Settings(BaseSettings): - a: str - - class Config: - env_file = str(p1) - - s = Settings(_env_file=p2) - assert s.a == 'new string' - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_env_file_override_none(tmp_path): - p = tmp_path / '.env' - p.write_text(test_env_file) - - class Settings(BaseSettings): - a: str = None - - class Config: - env_file = p - - s = Settings(_env_file=None) - assert s.a is None - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_env_file_not_a_file(env): - class Settings(BaseSettings): - a: str = None - - env.set('A', 'ignore non-file') - s = Settings(_env_file='tests/') - assert s.a == 'ignore non-file' - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_read_env_file_cast_sensitive(tmp_path): - p = tmp_path / '.env' - p.write_text('a="test"\nB=123') - - assert read_env_file(p) == {'a': 'test', 'b': '123'} - assert read_env_file(p, case_sensitive=True) == {'a': 'test', 'B': '123'} - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_read_env_file_syntax_wrong(tmp_path): - p = tmp_path / '.env' - p.write_text('NOT_AN_ASSIGNMENT') - - assert read_env_file(p, case_sensitive=True) == {'NOT_AN_ASSIGNMENT': None} - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_env_file_example(tmp_path): - p = tmp_path / '.env' - p.write_text( - """\ -# ignore comment -ENVIRONMENT="production" -REDIS_ADDRESS=localhost:6379 -MEANING_OF_LIFE=42 -MY_VAR='Hello world' -""" - ) - - class Settings(BaseSettings): - environment: str - redis_address: str - meaning_of_life: int - my_var: str - - s = Settings(_env_file=str(p)) - assert s.dict() == { - 'environment': 'production', - 'redis_address': 'localhost:6379', - 'meaning_of_life': 42, - 'my_var': 'Hello world', - } - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_env_file_custom_encoding(tmp_path): - p = tmp_path / '.env' - p.write_text('pika=p!±@', encoding='latin-1') - - class Settings(BaseSettings): - pika: str - - with pytest.raises(UnicodeDecodeError): - Settings(_env_file=str(p)) - - s = Settings(_env_file=str(p), _env_file_encoding='latin-1') - assert s.dict() == {'pika': 'p!±@'} - - -test_default_env_file = """\ -debug_mode=true -host=localhost -Port=8000 -""" - -test_prod_env_file = """\ -debug_mode=false -host=https://example.com/services -""" - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_multiple_env_file(tmp_path): - base_env = tmp_path / '.env' - base_env.write_text(test_default_env_file) - prod_env = tmp_path / '.env.prod' - prod_env.write_text(test_prod_env_file) - - class Settings(BaseSettings): - debug_mode: bool - host: str - port: int - - class Config: - env_file = [base_env, prod_env] - - s = Settings() - assert s.debug_mode is False - assert s.host == 'https://example.com/services' - assert s.port == 8000 - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_multiple_env_file_encoding(tmp_path): - base_env = tmp_path / '.env' - base_env.write_text('pika=p!±@', encoding='latin-1') - prod_env = tmp_path / '.env.prod' - prod_env.write_text('pika=chu!±@', encoding='latin-1') - - class Settings(BaseSettings): - pika: str - - s = Settings(_env_file=[base_env, prod_env], _env_file_encoding='latin-1') - assert s.pika == 'chu!±@' - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_read_dotenv_vars(tmp_path): - base_env = tmp_path / '.env' - base_env.write_text(test_default_env_file) - prod_env = tmp_path / '.env.prod' - prod_env.write_text(test_prod_env_file) - - source = EnvSettingsSource(env_file=[base_env, prod_env], env_file_encoding='utf8') - assert source._read_env_files(case_sensitive=False) == { - 'debug_mode': 'false', - 'host': 'https://example.com/services', - 'port': '8000', - } - - assert source._read_env_files(case_sensitive=True) == { - 'debug_mode': 'false', - 'host': 'https://example.com/services', - 'Port': '8000', - } - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_read_dotenv_vars_when_env_file_is_none(): - assert EnvSettingsSource(env_file=None, env_file_encoding=None)._read_env_files(case_sensitive=False) == {} - - -@pytest.mark.skipif(dotenv, reason='python-dotenv is installed') -def test_dotenv_not_installed(tmp_path): - p = tmp_path / '.env' - p.write_text('a=b') - - class Settings(BaseSettings): - a: str - - with pytest.raises(ImportError, match=r'^python-dotenv is not installed, run `pip install pydantic\[dotenv\]`$'): - Settings(_env_file=p) - - -def test_alias_set(env): - class Settings(BaseSettings): - foo: str = 'default foo' - bar: str = 'bar default' - - class Config: - fields = {'foo': {'env': 'foo_env'}} - - assert Settings.__fields__['bar'].name == 'bar' - assert Settings.__fields__['bar'].alias == 'bar' - assert Settings.__fields__['foo'].name == 'foo' - assert Settings.__fields__['foo'].alias == 'foo' - - class SubSettings(Settings): - spam: str = 'spam default' - - assert SubSettings.__fields__['bar'].name == 'bar' - assert SubSettings.__fields__['bar'].alias == 'bar' - assert SubSettings.__fields__['foo'].name == 'foo' - assert SubSettings.__fields__['foo'].alias == 'foo' - - assert SubSettings().dict() == {'foo': 'default foo', 'bar': 'bar default', 'spam': 'spam default'} - env.set('foo_env', 'fff') - assert SubSettings().dict() == {'foo': 'fff', 'bar': 'bar default', 'spam': 'spam default'} - env.set('bar', 'bbb') - assert SubSettings().dict() == {'foo': 'fff', 'bar': 'bbb', 'spam': 'spam default'} - env.set('spam', 'sss') - assert SubSettings().dict() == {'foo': 'fff', 'bar': 'bbb', 'spam': 'sss'} - - -def test_prefix_on_parent(env): - class MyBaseSettings(BaseSettings): - var: str = 'old' - - class MySubSettings(MyBaseSettings): - class Config: - env_prefix = 'PREFIX_' - - assert MyBaseSettings().dict() == {'var': 'old'} - assert MySubSettings().dict() == {'var': 'old'} - env.set('PREFIX_VAR', 'new') - assert MyBaseSettings().dict() == {'var': 'old'} - assert MySubSettings().dict() == {'var': 'new'} - - -def test_frozenset(env): - class Settings(BaseSettings): - foo: str = 'default foo' - - class Config: - fields = {'foo': {'env': frozenset(['foo_a', 'foo_b'])}} - - assert Settings.__fields__['foo'].field_info.extra['env_names'] == frozenset({'foo_a', 'foo_b'}) - - assert Settings().dict() == {'foo': 'default foo'} - env.set('foo_a', 'x') - assert Settings().dict() == {'foo': 'x'} - - -def test_secrets_path(tmp_path): - p = tmp_path / 'foo' - p.write_text('foo_secret_value_str') - - class Settings(BaseSettings): - foo: str - - class Config: - secrets_dir = tmp_path - - assert Settings().dict() == {'foo': 'foo_secret_value_str'} - - -def test_secrets_case_sensitive(tmp_path): - (tmp_path / 'SECRET_VAR').write_text('foo_env_value_str') - - class Settings(BaseSettings): - secret_var: Optional[str] - - class Config: - secrets_dir = tmp_path - case_sensitive = True - - assert Settings().dict() == {'secret_var': None} - - -def test_secrets_case_insensitive(tmp_path): - (tmp_path / 'SECRET_VAR').write_text('foo_env_value_str') - - class Settings(BaseSettings): - secret_var: Optional[str] - - class Config: - secrets_dir = tmp_path - case_sensitive = False - - settings = Settings().dict() - assert settings == {'secret_var': 'foo_env_value_str'} - - -def test_secrets_path_url(tmp_path): - (tmp_path / 'foo').write_text('http://www.example.com') - (tmp_path / 'bar').write_text('snap') - - class Settings(BaseSettings): - foo: HttpUrl - bar: SecretStr - - class Config: - secrets_dir = tmp_path - - assert Settings().dict() == {'foo': 'http://www.example.com', 'bar': SecretStr('snap')} - - -def test_secrets_path_json(tmp_path): - p = tmp_path / 'foo' - p.write_text('{"a": "b"}') - - class Settings(BaseSettings): - foo: Dict[str, str] - - class Config: - secrets_dir = tmp_path - - assert Settings().dict() == {'foo': {'a': 'b'}} - - -def test_secrets_path_invalid_json(tmp_path): - p = tmp_path / 'foo' - p.write_text('{"a": "b"') - - class Settings(BaseSettings): - foo: Dict[str, str] - - class Config: - secrets_dir = tmp_path - - with pytest.raises(SettingsError, match='error parsing env var "foo"'): - Settings() - - -def test_secrets_missing(tmp_path): - class Settings(BaseSettings): - foo: str - - class Config: - secrets_dir = tmp_path - - with pytest.raises(ValidationError) as exc_info: - Settings() - - assert exc_info.value.errors() == [{'loc': ('foo',), 'msg': 'field required', 'type': 'value_error.missing'}] - - -def test_secrets_invalid_secrets_dir(tmp_path): - p1 = tmp_path / 'foo' - p1.write_text('foo_secret_value_str') - - class Settings(BaseSettings): - foo: str - - class Config: - secrets_dir = p1 - - with pytest.raises(SettingsError, match='secrets_dir must reference a directory, not a file'): - Settings() - - -@pytest.mark.skipif(sys.platform.startswith('win'), reason='windows paths break regex') -def test_secrets_missing_location(tmp_path): - class Settings(BaseSettings): - class Config: - secrets_dir = tmp_path / 'does_not_exist' - - with pytest.warns(UserWarning, match=f'directory "{tmp_path}/does_not_exist" does not exist'): - Settings() - - -@pytest.mark.skipif(sys.platform.startswith('win'), reason='windows paths break regex') -def test_secrets_file_is_a_directory(tmp_path): - p1 = tmp_path / 'foo' - p1.mkdir() - - class Settings(BaseSettings): - foo: Optional[str] - - class Config: - secrets_dir = tmp_path - - with pytest.warns(UserWarning, match=f'attempted to load secret file "{tmp_path}/foo" but found a directory inste'): - Settings() - - -@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed') -def test_secrets_dotenv_precedence(tmp_path): - s = tmp_path / 'foo' - s.write_text('foo_secret_value_str') - - e = tmp_path / '.env' - e.write_text('foo=foo_env_value_str') - - class Settings(BaseSettings): - foo: str - - class Config: - secrets_dir = tmp_path - - assert Settings(_env_file=e).dict() == {'foo': 'foo_env_value_str'} - - -def test_external_settings_sources_precedence(env): - def external_source_0(settings: BaseSettings) -> Dict[str, str]: - return {'apple': 'value 0', 'banana': 'value 2'} - - def external_source_1(settings: BaseSettings) -> Dict[str, str]: - return {'apple': 'value 1', 'raspberry': 'value 3'} - - class Settings(BaseSettings): - apple: str - banana: str - raspberry: str - - class Config: - @classmethod - def customise_sources( - cls, - init_settings: SettingsSourceCallable, - env_settings: SettingsSourceCallable, - file_secret_settings: SettingsSourceCallable, - ) -> Tuple[SettingsSourceCallable, ...]: - return init_settings, env_settings, file_secret_settings, external_source_0, external_source_1 - - env.set('banana', 'value 1') - assert Settings().dict() == {'apple': 'value 0', 'banana': 'value 1', 'raspberry': 'value 3'} - - -def test_external_settings_sources_filter_env_vars(): - vault_storage = {'user:password': {'apple': 'value 0', 'banana': 'value 2'}} - - class VaultSettingsSource: - def __init__(self, user: str, password: str): - self.user = user - self.password = password - - def __call__(self, settings: BaseSettings) -> Dict[str, str]: - vault_vars = vault_storage[f'{self.user}:{self.password}'] - return { - field.alias: vault_vars[field.name] - for field in settings.__fields__.values() - if field.name in vault_vars - } - - class Settings(BaseSettings): - apple: str - banana: str - - class Config: - @classmethod - def customise_sources( - cls, - init_settings: SettingsSourceCallable, - env_settings: SettingsSourceCallable, - file_secret_settings: SettingsSourceCallable, - ) -> Tuple[SettingsSourceCallable, ...]: - return ( - init_settings, - env_settings, - file_secret_settings, - VaultSettingsSource(user='user', password='password'), - ) - - assert Settings().dict() == {'apple': 'value 0', 'banana': 'value 2'} - - -def test_customise_sources_empty(): - class Settings(BaseSettings): - apple: str = 'default' - banana: str = 'default' - - class Config: - @classmethod - def customise_sources(cls, *args, **kwargs): - return () - - assert Settings().dict() == {'apple': 'default', 'banana': 'default'} - assert Settings(apple='xxx').dict() == {'apple': 'default', 'banana': 'default'} - - -def test_builtins_settings_source_repr(): - assert ( - repr(InitSettingsSource(init_kwargs={'apple': 'value 0', 'banana': 'value 1'})) - == "InitSettingsSource(init_kwargs={'apple': 'value 0', 'banana': 'value 1'})" - ) - assert ( - repr(EnvSettingsSource(env_file='.env', env_file_encoding='utf-8')) - == "EnvSettingsSource(env_file='.env', env_file_encoding='utf-8', env_nested_delimiter=None)" - ) - assert repr(SecretsSettingsSource(secrets_dir='/secrets')) == "SecretsSettingsSource(secrets_dir='/secrets')" - - -def _parse_custom_dict(value: str) -> Callable[[str], Dict[int, str]]: - """A custom parsing function passed into env parsing test.""" - res = {} - for part in value.split(','): - k, v = part.split('=') - res[int(k)] = v - return res - - -def test_env_setting_source_custom_env_parse(env): - class Settings(BaseSettings): - top: Dict[int, str] - - class Config: - @classmethod - def parse_env_var(cls, field_name: str, raw_val: str): - if field_name == 'top': - return _parse_custom_dict(raw_val) - return cls.json_loads(raw_val) - - with pytest.raises(ValidationError): - Settings() - env.set('top', '1=apple,2=banana') - s = Settings() - assert s.top == {1: 'apple', 2: 'banana'} - - -def test_env_settings_source_custom_env_parse_is_bad(env): - class Settings(BaseSettings): - top: Dict[int, str] - - class Config: - @classmethod - def parse_env_var(cls, field_name: str, raw_val: str): - if field_name == 'top': - return int(raw_val) - return cls.json_loads(raw_val) - - env.set('top', '1=apple,2=banana') - with pytest.raises(SettingsError, match='error parsing env var "top"'): - Settings() - - -def test_secret_settings_source_custom_env_parse(tmp_path): - p = tmp_path / 'top' - p.write_text('1=apple,2=banana') - - class Settings(BaseSettings): - top: Dict[int, str] - - class Config: - secrets_dir = tmp_path - - @classmethod - def parse_env_var(cls, field_name: str, raw_val: str): - if field_name == 'top': - return _parse_custom_dict(raw_val) - return cls.json_loads(raw_val) - - s = Settings() - assert s.top == {1: 'apple', 2: 'banana'}