├── betterconf ├── py.typed ├── _specials.py ├── exceptions.py ├── __init__.py ├── decorator.py ├── caster.py ├── _field.py ├── provider.py └── _config.py ├── examples ├── .env ├── README.md ├── simple.py ├── references.py ├── casters.py └── namespace.py ├── Makefile ├── .pre-commit-config.yaml ├── pyproject.toml ├── LICENSE ├── .gitignore ├── README.md └── tests └── test_config.py /betterconf/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/.env: -------------------------------------------------------------------------------- 1 | ACCOUNT_USERNAME=john 2 | ACCOUNT_PASSWORD=hello_123 3 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | Simple examples. 2 | 3 | ### Simple.py 4 | 5 | ```sh 6 | ACCOUNT_USERNAME=john ACCOUNT_PASSWORD=hello_123 ACCOUNT_ID=100500 ./simple.py 7 | ``` 8 | 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | py := poetry run 2 | 3 | install: 4 | pip install poetry 5 | poetry install 6 | 7 | test: 8 | $(py) pytest 9 | 10 | black: 11 | $(py) black betterconf/ tests/ 12 | 13 | pre-commit: 14 | $(py) pre-commit install 15 | 16 | mypy: 17 | $(py) mypy betterconf/ 18 | -------------------------------------------------------------------------------- /examples/simple.py: -------------------------------------------------------------------------------- 1 | from betterconf import betterconf 2 | from betterconf import Alias 3 | 4 | @betterconf(prefix="ACCOUNT") 5 | class AccountInfo: 6 | username: Alias[str, "USERNAME"] 7 | password: Alias[str, "PASSWORD"] 8 | id: Alias[int, "ID"] 9 | 10 | 11 | if __name__ == "__main__": 12 | info = AccountInfo() 13 | print( 14 | f"Username is {info.username}, password is {info.password} and ID is {info.id}" 15 | ) 16 | -------------------------------------------------------------------------------- /examples/references.py: -------------------------------------------------------------------------------- 1 | from betterconf import betterconf, reference_field, field 2 | from betterconf import Alias 3 | 4 | 5 | @betterconf 6 | class MoneyConfig: 7 | money: Alias[int, "MONEY_VAR"] = 10 8 | name = field("NAME_VAR", default="Johnny") 9 | 10 | money_if_a_lot: int = reference_field(money, func=lambda m: m * 1000) 11 | greeting: str = reference_field(money, name, func=lambda m, n: f"Hello, my name is {n} and I'm rich for {m}") 12 | 13 | if __name__ == "__main__": 14 | cfg = MoneyConfig() 15 | print(f"{cfg.greeting}. I could have ${cfg.money_if_a_lot}...") 16 | -------------------------------------------------------------------------------- /betterconf/_specials.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing 3 | 4 | _T = typing.TypeVar("_T") 5 | _T2 = typing.TypeVar("_T2") 6 | 7 | 8 | class _Special: 9 | pass 10 | 11 | 12 | def is_special(tp: typing.Any) -> bool: 13 | return isinstance(tp, _Special) 14 | 15 | 16 | @dataclasses.dataclass(eq=True, frozen=True) 17 | class AliasSpecial(_Special): 18 | tp: type 19 | alias: str 20 | 21 | @classmethod 22 | def __class_getitem__(cls, *args: typing.Tuple[type, str]) -> typing.Self: 23 | parsed = args[0] 24 | 25 | return cls(parsed[0], parsed[1]) 26 | 27 | 28 | if typing.TYPE_CHECKING: 29 | Alias = typing.Annotated 30 | 31 | else: 32 | Alias = AliasSpecial 33 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v2.3.0 5 | hooks: 6 | - id: trailing-whitespace 7 | - repo: https://github.com/psf/black 8 | rev: 19.3b0 9 | hooks: 10 | - id: black 11 | - repo: https://github.com/asottile/reorder_python_imports 12 | rev: v1.3.5 13 | hooks: 14 | - id: reorder-python-imports 15 | language_version: python3 16 | - repo: https://github.com/asottile/pyupgrade 17 | rev: v1.11.1 18 | hooks: 19 | - id: pyupgrade 20 | - repo: https://github.com/pre-commit/mirrors-mypy 21 | rev: v0.761 22 | hooks: 23 | - id: mypy 24 | -------------------------------------------------------------------------------- /betterconf/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from betterconf.caster import AbstractCaster 5 | 6 | 7 | class BetterconfError(Exception): 8 | pass 9 | 10 | 11 | class ImpossibleToCastError(BetterconfError): 12 | def __init__(self, val: str, caster: "AbstractCaster"): 13 | self.val = val 14 | self.caster = caster 15 | self.message = f'Could not cast "{val}" with {caster.__class__.__name__}' 16 | super().__init__(self.message) 17 | 18 | 19 | class VariableNotFoundError(BetterconfError): 20 | def __init__(self, variable_name: str): 21 | self.variable_name = variable_name 22 | self.message = f"Variable ({variable_name}) hasn't been found" 23 | super().__init__(self.message) 24 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "betterconf" 3 | version = "4.5.0" 4 | description = "Configs in Python made smooth and simple" 5 | authors = ["prostomarkeloff"] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/prostomarkeloff/betterconf" 9 | repository = "https://github.com/prostomarkeloff/betterconf" 10 | documentation = "https://github.com/prostomarkeloff/betterconf" 11 | keywords = ["configs", "config", "env", ".env"] 12 | classifiers = ["Topic :: Software Development :: Libraries :: Python Modules"] 13 | 14 | [tool.poetry.dependencies] 15 | python = "^3.11" 16 | 17 | 18 | [tool.poetry.group.dev.dependencies] 19 | pytest = "^8.2.2" 20 | ruff = "^0.6.9" 21 | 22 | [build-system] 23 | requires = ["poetry>=1.0.0"] 24 | build-backend = "poetry.masonry.api" 25 | -------------------------------------------------------------------------------- /examples/casters.py: -------------------------------------------------------------------------------- 1 | import json 2 | import typing 3 | from betterconf import betterconf, field, JSONProvider, AbstractCaster, ImpossibleToCastError 4 | from dataclasses import dataclass 5 | 6 | @dataclass 7 | class UserData: 8 | login: str 9 | password: str 10 | user_id: int 11 | 12 | class UserDataCaster(AbstractCaster): 13 | def cast(self, val: str) -> typing.Union[typing.Any, typing.NoReturn]: 14 | try: 15 | parsed = json.loads(val) 16 | return UserData(**parsed) 17 | except (json.JSONDecodeError, TypeError): 18 | raise ImpossibleToCastError(val, self) 19 | 20 | pretend_config = json.dumps({"id": 14, "user": {"login": "admin", "password": "admin", "user_id": 1}}) 21 | 22 | @betterconf(provider=JSONProvider(pretend_config)) 23 | class Config: 24 | id: int 25 | user: UserData = field(caster=UserDataCaster()) 26 | 27 | cfg = Config() 28 | print(f"Id is {cfg.id}\nUser data: {cfg.user.login}:{cfg.user.password}|{cfg.user.user_id}") 29 | -------------------------------------------------------------------------------- /examples/namespace.py: -------------------------------------------------------------------------------- 1 | from betterconf import betterconf 2 | 3 | @betterconf 4 | class BaseConfig: 5 | debug: bool = False 6 | 7 | @betterconf(subconfig=True) 8 | class Integration: 9 | @betterconf(subconfig=True) 10 | class SMTP: 11 | server: str = "smtp.gmail.com" 12 | port: int = 465 13 | login: str 14 | password: str 15 | 16 | @betterconf(prefix="PROD") 17 | class Prod(BaseConfig): 18 | @betterconf(subconfig=True) 19 | class Integration(BaseConfig.Integration): 20 | @betterconf(subconfig=True) 21 | class SMTP(BaseConfig.Integration.SMTP): 22 | login: str = "prod@gmail.com" 23 | password: str = "123456" 24 | 25 | 26 | @betterconf(prefix="TEST") 27 | class Test(BaseConfig): 28 | debug: bool = True 29 | 30 | @betterconf(subconfig=True) 31 | class Integration(BaseConfig.Integration): 32 | @betterconf(subconfig=True) 33 | class SMTP(BaseConfig.Integration.SMTP): 34 | login: str = "test@gmail.com" 35 | password: str = "123456" 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 prostomarkeloff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /betterconf/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python configs made smooth and easy. 3 | """ 4 | 5 | from .decorator import betterconf 6 | from ._config import Prefix 7 | from ._field import field, Field, constant_field, reference_field, value 8 | from ._specials import Alias 9 | from .provider import ( 10 | AbstractProvider, 11 | JSONProvider, 12 | EnvironmentProvider, 13 | DotenvProvider, 14 | ) 15 | from .caster import ( 16 | to_int, 17 | to_bool, 18 | to_list, 19 | to_float, 20 | to_loguru_log_level, 21 | to_logging_log_level, 22 | AbstractCaster, 23 | ) 24 | from .exceptions import ( 25 | ImpossibleToCastError, 26 | BetterconfError, 27 | VariableNotFoundError, 28 | ) 29 | 30 | __author__ = "prostomarkeloff" 31 | __all__ = ( 32 | "betterconf", 33 | "field", 34 | "Field", 35 | "constant_field", 36 | "value", 37 | "reference_field", 38 | "AbstractProvider", 39 | "JSONProvider", 40 | "Alias", 41 | "Prefix", 42 | "EnvironmentProvider", 43 | "to_int", 44 | "to_bool", 45 | "to_list", 46 | "to_float", 47 | "to_loguru_log_level", 48 | "to_logging_log_level", 49 | "AbstractCaster", 50 | "BetterconfError", 51 | "VariableNotFoundError", 52 | "ImpossibleToCastError", 53 | "DotenvProvider", 54 | "__author__", 55 | ) 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | 132 | # some stuff 133 | .idea/ 134 | .vscode/ 135 | poetry.lock 136 | t.py 137 | -------------------------------------------------------------------------------- /betterconf/decorator.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from betterconf.provider import AbstractProvider, DEFAULT_PROVIDER 3 | from betterconf._config import ConfigInner, ConfigProto, Prefix 4 | 5 | class_T = typing.TypeVar("class_T", bound=type) 6 | 7 | 8 | @typing.overload 9 | def betterconf( 10 | cls: class_T, 11 | provider: typing.Optional[AbstractProvider] = None, 12 | prefix: typing.Optional[typing.Union[Prefix, str]] = None, 13 | subconfig: bool = False, 14 | ) -> class_T: ... 15 | 16 | 17 | @typing.overload 18 | def betterconf( 19 | cls: None = None, 20 | provider: typing.Optional[AbstractProvider] = None, 21 | prefix: typing.Optional[typing.Union[Prefix, str]] = None, 22 | subconfig: bool = False, 23 | ) -> typing.Callable[[class_T], class_T]: ... 24 | 25 | 26 | def betterconf( 27 | cls: typing.Optional[class_T] = None, 28 | provider: typing.Optional[AbstractProvider] = None, 29 | prefix: typing.Optional[typing.Union[Prefix, str]] = None, 30 | subconfig: bool = False, 31 | ) -> typing.Union[class_T, typing.Callable[[class_T], class_T]]: 32 | def inner(cls: class_T) -> class_T: 33 | def __init__( 34 | self: ConfigProto, 35 | _provider_: typing.Optional[AbstractProvider] = None, 36 | **to_override: typing.Any, 37 | ): 38 | for field in self.__bc_inner__.fields: 39 | if not field.field.provider: 40 | field.field.provider = provider 41 | 42 | if _provider_: 43 | field.field.provider = _provider_ 44 | 45 | if field.name_in_python in to_override: 46 | field.field.default = to_override[field.name_in_python] 47 | setattr(self, field.name_in_python, field.field.value) 48 | else: 49 | setattr(self, field.name_in_python, field.field.value) 50 | 51 | for sub_config in self.__bc_inner__.sub_configs: 52 | if not sub_config.cfg.__bc_provider__: 53 | sub_config.cfg.__bc_provider__ = provider 54 | 55 | if _provider_: 56 | sub_config.cfg.__bc_provider__ = _provider_ 57 | 58 | if sub_config.name in to_override: 59 | setattr(self, sub_config.name, to_override[sub_config.name]) 60 | else: 61 | config = sub_config.cfg(**to_override) 62 | setattr(self, sub_config.name, config) 63 | 64 | nonlocal provider 65 | if subconfig is False: 66 | provider = provider or DEFAULT_PROVIDER 67 | 68 | nonlocal prefix 69 | if isinstance(prefix, str): 70 | prefix = Prefix(prefix) 71 | 72 | cls.__bc_subconfig__ = subconfig 73 | cls.__bc_inner__ = ConfigInner.parse_into(cls, provider, prefix) 74 | cls.__bc_prefix__ = prefix 75 | cls.__bc_provider__ = provider 76 | 77 | setattr(cls, "__init__", __init__) 78 | return cls 79 | 80 | if cls is None: 81 | return inner 82 | 83 | return inner(cls) 84 | -------------------------------------------------------------------------------- /betterconf/caster.py: -------------------------------------------------------------------------------- 1 | import typing 2 | import logging 3 | 4 | from betterconf.exceptions import ImpossibleToCastError 5 | 6 | VT = typing.TypeVar("VT") 7 | 8 | 9 | class AbstractCaster: 10 | def cast(self, val: str) -> typing.Union[typing.Any, typing.NoReturn]: 11 | """Try to cast or return val""" 12 | raise NotImplementedError() 13 | 14 | 15 | class ConstantCaster(AbstractCaster, typing.Generic[VT]): 16 | ABLE_TO_CAST: typing.Dict[ 17 | typing.Union[str, typing.Tuple[str, ...]], typing.Any 18 | ] = {} 19 | 20 | def cast(self, val: str) -> typing.Union[VT, typing.NoReturn]: 21 | """Cast using ABLE_TO_CAST dictionary as in BoolCaster""" 22 | if val in self.ABLE_TO_CAST: 23 | converted = self.ABLE_TO_CAST.get(val.lower()) 24 | converted = typing.cast(VT, converted) 25 | return converted 26 | else: 27 | for key in self.ABLE_TO_CAST: 28 | if isinstance(key, tuple) and val.lower() in key: 29 | return self.ABLE_TO_CAST[key] 30 | elif isinstance(key, str) and val.lower() == key: 31 | return self.ABLE_TO_CAST[key] 32 | raise ImpossibleToCastError(val, self) 33 | 34 | 35 | class BoolCaster(ConstantCaster[bool]): 36 | ABLE_TO_CAST = { 37 | "true": True, 38 | "1": True, 39 | "yes": True, 40 | "ok": True, 41 | "on": True, 42 | "false": False, 43 | "0": False, 44 | "no": False, 45 | "off": False, 46 | } 47 | 48 | 49 | class IntCaster(AbstractCaster): 50 | def cast(self, val: str) -> typing.Union[int, typing.NoReturn]: 51 | try: 52 | as_int = int(val) 53 | return as_int 54 | except ValueError: 55 | raise ImpossibleToCastError(val, self) 56 | 57 | 58 | class FloatCaster(AbstractCaster): 59 | def cast(self, val: str) -> typing.Union[float, typing.NoReturn]: 60 | val = val.replace(",", ".") 61 | try: 62 | as_float = float(val) 63 | return as_float 64 | except ValueError: 65 | raise ImpossibleToCastError(val, self) 66 | 67 | 68 | class ListCaster(AbstractCaster): 69 | def __init__(self, separator: str = ","): 70 | self.separator = separator 71 | 72 | def cast(self, val: str) -> typing.List[typing.Any]: 73 | if val.endswith(self.separator): 74 | val = val[0 : len(val) - len(self.separator)] 75 | return val.split(self.separator) 76 | 77 | 78 | class LoggingLogLevelCaster(ConstantCaster[int]): 79 | ABLE_TO_CAST = { 80 | "CRITICAL": logging.CRITICAL, 81 | "FATAL": logging.FATAL, 82 | "ERROR": logging.ERROR, 83 | "WARN": logging.WARN, 84 | "WARNING": logging.WARNING, 85 | "INFO": logging.INFO, 86 | "DEBUG": logging.DEBUG, 87 | "NOTSET": logging.NOTSET, 88 | } 89 | 90 | 91 | class LoguruLogLevelCaster(ConstantCaster[str]): 92 | levels = ["TRACE", "DEBUG", "INFO", "SUCCESS", "WARNING", "ERROR", "CRITICAL"] 93 | ABLE_TO_CAST = {level.lower(): level for level in levels} 94 | 95 | 96 | class NothingCaster(AbstractCaster): 97 | """Caster who does nothing""" 98 | 99 | def cast(self, val: str) -> str: 100 | return val 101 | 102 | 103 | to_bool = BoolCaster() 104 | to_int = IntCaster() 105 | to_float = FloatCaster() 106 | to_list = ListCaster() 107 | to_logging_log_level = LoggingLogLevelCaster() 108 | to_loguru_log_level = LoguruLogLevelCaster() 109 | DEFAULT_CASTER = NothingCaster() 110 | 111 | BUILTIN_CASTERS = { 112 | bool: to_bool, 113 | int: to_int, 114 | float: to_float, 115 | list: to_list, 116 | str: DEFAULT_CASTER, # for type hints 117 | } 118 | 119 | __all__ = ( 120 | "to_bool", 121 | "to_int", 122 | "to_float", 123 | "to_list", 124 | "to_logging_log_level", 125 | "to_loguru_log_level", 126 | "AbstractCaster", 127 | "ConstantCaster", 128 | "DEFAULT_CASTER", 129 | ) 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Configs in Python made smooth and simple 2 | 3 | Betterconf (**better config**) is a Python library for project configuration 4 | management. It allows you define your config like a regular Python class. 5 | 6 | Features: 7 | 8 | * Easy to hack. 9 | * Less boilerplate. 10 | * Minimal code to do big things. 11 | * Zero dependencies. Only vanilla Python >=3.11 12 | 13 | Betterconf is heavily typed, so your editors and typecheckers will be happy with it. 14 | 15 | It's not that huge as Pydantic, so takes like 5 minutes to dive into. 16 | 17 | Betterconf is highly customizable, so you can do anything with it. 18 | 19 | ## Installation 20 | 21 | I recommend you to use poetry: 22 | 23 | ```sh 24 | poetry add betterconf 25 | ``` 26 | 27 | However, you can use pip: 28 | 29 | ```sh 30 | pip install betterconf 31 | ``` 32 | 33 | ## How to? 34 | 35 | Betterconf is very intuitive, so you don't need special knowledge to use it. We'll cover the basics. 36 | 37 | The most simple config will look like this: 38 | ```python 39 | from betterconf import betterconf 40 | 41 | @betterconf 42 | class Config: 43 | LOGIN: str 44 | PASSWORD: str 45 | 46 | cfg = Config() 47 | print(config.LOGIN) 48 | ``` 49 | 50 | Let's dive into what it does. By default, betterconf uses `EnvironmentProvider`, getting values with `os.getenv`, 51 | so you can run this example with `LOGIN=vasya PASSWORD=admin python config.py` and simply get your login. 52 | 53 | 54 | There is a very usable thing in our fancy-web-world, called `.env`s. Betterconf, since 4.5.0 automatically supports them out-of-the-box! See it: 55 | 56 | ```python 57 | from betterconf import betterconf, DotenvProvider 58 | 59 | # here betterconf gets values from `.env` file, you can change name or the path passing it as the first 60 | # argument to provider. 61 | # also, auto_load lets you not to write `provider.load_into_env` (loading variables to os.environ) 62 | # or `provider.load_into_provider()` (loading them into the provider's inner storage). 63 | @betterconf(provider=DotenvProvider(auto_load=True)) 64 | class Config: 65 | val1: int 66 | val2: str 67 | name: str 68 | 69 | cfg = Config() 70 | print(cfg.val1, cfg.val2, cfg.name) 71 | ``` 72 | 73 | Your `.env` then looks like this: 74 | 75 | ``` 76 | name="John Wicked" 77 | val1=12 78 | val2="testing value" 79 | ``` 80 | 81 | 82 | But what if you need a different provider? Betterconf lets you set providers as for config itself and for each field respectively. 83 | 84 | ```python 85 | import json 86 | from betterconf import Alias 87 | from betterconf import betterconf, field 88 | from betterconf import JSONProvider, AbstractProvider 89 | 90 | sample_json_config = json.dumps({"field": {"from": {"json": 123}}}) 91 | 92 | # our provider, that just gives the name of field back 93 | class NameProvider(AbstractProvider): 94 | def get(self, name: str) -> str: 95 | return name 96 | 97 | @betterconf(provider=NameProvider()) 98 | class Config: 99 | # value will be got from NameProvider and will simply be `my_fancy_name` 100 | my_fancy_name: str 101 | # here we get value from JSONProvider; the default nested_access is '.' 102 | field_from_json: Alias[int, "field::from::json"] = field(provider=JSONProvider(sample_json_config, nested_access="::")) 103 | 104 | ``` 105 | 106 | Betterconf casts primitive types itself, they include list, float, str, int. But if you need specific caster, say for complex object, you can write your own. 107 | 108 | ```python 109 | from betterconf import betterconf 110 | from betterconf import AbstractCaster, field 111 | 112 | class DashesToDotsCaster(AbstractCaster): 113 | def cast(self, val: str) -> str: 114 | return val.replace("_", ".") 115 | 116 | @betterconf 117 | class Config: 118 | simple_int: int 119 | value: str = field(caster=DashesToDotsCaster()) 120 | 121 | print(Config(value="privet_mir", simple_int="55666").value) 122 | ``` 123 | 124 | Subconfigs and referencing one field in another declaration is also available. Check out examples folder. 125 | 126 | ## License 127 | This project is licensed under MIT License. 128 | 129 | See [LICENSE](LICENSE) for details. 130 | 131 | 132 | Made with :heart: by [prostomarkeloff](https://github.com/prostomarkeloff) and our contributors. 133 | -------------------------------------------------------------------------------- /betterconf/_field.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from typing import TypeVarTuple 3 | 4 | from betterconf.caster import AbstractCaster 5 | from betterconf.caster import DEFAULT_CASTER 6 | from betterconf.exceptions import VariableNotFoundError, ImpossibleToCastError 7 | from betterconf.provider import DEFAULT_PROVIDER, AbstractProvider 8 | 9 | 10 | class Sentinel: 11 | @classmethod 12 | def is_setinel(cls, obj: typing.Any) -> bool: 13 | return isinstance(obj, cls) 14 | 15 | 16 | _NO_DEFAULT = Sentinel() 17 | 18 | T = typing.TypeVar("T") 19 | Ts = TypeVarTuple("Ts") 20 | SentinelOrT = typing.Union[Sentinel, T] 21 | 22 | 23 | class _Field(typing.Generic[T]): 24 | # NB: fields are: 25 | # 1. lazy evaluated (when .value is called, I'm sure like always when initializing), fields are 26 | # 2. one-timers: every time the .value is called the recompute their value (that's needed for inheritance or 27 | # initializing this config with new params) 28 | 29 | def __init__( 30 | self, 31 | name: typing.Optional[str] = None, 32 | default: SentinelOrT[T] = _NO_DEFAULT, 33 | provider: typing.Optional[AbstractProvider] = None, 34 | caster: AbstractCaster = DEFAULT_CASTER, 35 | ignore_caster_error: bool = False, 36 | ): 37 | self.name = name 38 | self.provider = provider 39 | self.default = default 40 | self.caster = caster 41 | self.ignore_caster_error = ignore_caster_error 42 | 43 | def _get_value(self) -> T: 44 | try: 45 | if self.name is None: 46 | raise VariableNotFoundError("No name was given, as is a default value") 47 | 48 | if not self.provider: 49 | self.provider = DEFAULT_PROVIDER 50 | 51 | inner_value = self.provider.get(self.name) 52 | except VariableNotFoundError as e: 53 | if isinstance(self.default, Sentinel): 54 | raise e 55 | 56 | if callable(self.default): 57 | return self.default() 58 | else: 59 | return self.default 60 | try: 61 | casted = self.caster.cast(inner_value) 62 | 63 | except ImpossibleToCastError as e: 64 | if self.ignore_caster_error: 65 | return e.val # type: ignore 66 | 67 | else: 68 | raise e 69 | 70 | else: 71 | return casted 72 | 73 | @property 74 | def value(self) -> T: 75 | return self._get_value() 76 | 77 | # can be used as `default=` 78 | def __call__(self, *_, **__: typing.Any): 79 | return self.value 80 | 81 | 82 | if typing.TYPE_CHECKING: 83 | Field = typing.Annotated[T, ...] 84 | else: 85 | Field = _Field 86 | 87 | 88 | def value( 89 | name: typing.Optional[str] = None, 90 | default: T | typing.Any = _NO_DEFAULT, 91 | provider: typing.Optional[AbstractProvider] = None, 92 | caster: AbstractCaster = DEFAULT_CASTER, 93 | ignore_caster_error: bool = False, 94 | ) -> T: 95 | """ 96 | Get a field and a value exactly when it's created 97 | """ 98 | f = _Field[T](name, default, provider, caster, ignore_caster_error) 99 | return f.value 100 | 101 | 102 | def field( 103 | name: typing.Optional[str] = None, 104 | default: T | typing.Any = _NO_DEFAULT, 105 | provider: typing.Optional[AbstractProvider] = None, 106 | caster: AbstractCaster = DEFAULT_CASTER, 107 | ignore_caster_error: bool = False, 108 | ) -> T: 109 | """ 110 | Create a field for your config 111 | """ 112 | return typing.cast( 113 | T, _Field[T](name, default, provider, caster, ignore_caster_error) 114 | ) 115 | 116 | 117 | def reference_field(*fields: *Ts, func: typing.Callable[[*Ts], T]) -> T: 118 | def _default() -> T: 119 | vars: typing.List[typing.Any] = [] 120 | for field in fields: 121 | if isinstance(field, _Field): 122 | vars.append(field.value) # type: ignore 123 | else: 124 | vars.append(field) 125 | 126 | return func(*vars) # type: ignore 127 | 128 | return typing.cast(T, _Field(default=_default)) 129 | 130 | 131 | def constant_field(const: T) -> T: 132 | return typing.cast(T, _Field(default=const)) 133 | 134 | 135 | __all__ = ("Field", "field", "value", "reference_field", "constant_field") 136 | -------------------------------------------------------------------------------- /betterconf/provider.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import typing 4 | 5 | from pathlib import Path 6 | from betterconf.exceptions import BetterconfError, VariableNotFoundError 7 | 8 | 9 | class AbstractProvider: 10 | """Implement this class and pass to `field`""" 11 | 12 | def get(self, name: str) -> str: 13 | """Return a value (str) or raise a `VariableNotFoundError`""" 14 | raise NotImplementedError() 15 | 16 | 17 | class EnvironmentProvider(AbstractProvider): 18 | """Default provider. Gets vals from environment""" 19 | 20 | def get(self, name: str) -> str: 21 | value = os.getenv(name) 22 | if value is None: 23 | raise VariableNotFoundError(name) 24 | return value 25 | 26 | 27 | class JSONProvider(AbstractProvider): 28 | @staticmethod 29 | def __bool_object_hook(inp: typing.Any) -> typing.Dict[typing.Any, typing.Any]: 30 | d: typing.Dict[typing.Any, typing.Any] = {} 31 | for k, v in inp.items(): 32 | if isinstance(v, bool): 33 | d[k] = str(v) 34 | continue 35 | elif isinstance(v, list): 36 | d[k] = json.dumps(v) 37 | continue 38 | d[k] = v 39 | return d 40 | 41 | def __init__(self, inp: str, nested_access: str = "."): 42 | # dirty hack cause betterconf itself deserializes objects and we have to implement clear interface based on 43 | # str`s 44 | self._content: typing.Union[typing.Any, typing.Dict[str, typing.Any]] = ( 45 | json.loads( 46 | inp, 47 | object_hook=self.__bool_object_hook, 48 | parse_int=lambda i: i, 49 | parse_float=lambda f: f, 50 | parse_constant=lambda c: c, 51 | ) 52 | ) 53 | self._nested_access = nested_access 54 | if not isinstance(self._content, dict): 55 | raise ValueError("JSONProvider doesn't know how to operate not on dicts") 56 | 57 | @classmethod 58 | def from_path(cls, path: str, nested_access: str = ".") -> typing.Self: 59 | return cls.from_file(open(path, mode="r"), nested_access) 60 | 61 | @classmethod 62 | def from_file(cls, file: typing.IO[str], nested_access: str = ".") -> typing.Self: 63 | contents = file.read() 64 | file.close() 65 | return cls(contents, nested_access) 66 | 67 | @classmethod 68 | def from_string(cls, inp: str, nested_access: str = "."): 69 | return cls(inp, nested_access) 70 | 71 | def get(self, name: str) -> str: 72 | nested = name.split(self._nested_access) 73 | result: typing.Optional[str] = None 74 | storage = self._content 75 | 76 | "hello.world == {'hello': {'world': 123}'" 77 | for k in nested: 78 | result = storage.get(k) # type: ignore 79 | storage = result # type: ignore 80 | 81 | if result is None or not isinstance(result, str): 82 | raise VariableNotFoundError(name) 83 | 84 | return result 85 | 86 | 87 | class DotenvProvider(AbstractProvider): 88 | def __init__( 89 | self, file_path: str | Path = ".env", *, auto_load: bool = False 90 | ) -> None: 91 | self.file_path = file_path 92 | self._loaded_into: typing.Literal["env", "in", None] = None 93 | 94 | self._environ = EnvironmentProvider() 95 | self._inner: dict[str, str] = {} 96 | 97 | self._auto_load = auto_load 98 | 99 | def _put_lines_to_vars(self, into: typing.Literal["env", "in"]): 100 | with open(self.file_path, "r") as f: 101 | lines = f.readlines() 102 | 103 | vars: dict[str, str] = {} 104 | for line in lines: 105 | if line == "\n": 106 | continue 107 | try: 108 | var_name, var_value = line.split("=", 1) 109 | except ValueError: 110 | raise BetterconfError( 111 | "DotenvProvider can't read your dotenv file because it seems to be broken" 112 | ) 113 | 114 | var_value = var_value.rstrip("\n") 115 | var_name = var_name.rstrip("\n") 116 | vars[var_name] = var_value 117 | 118 | self._loaded_into = into 119 | if into == "in": 120 | self._inner.update(vars) 121 | elif into == "env": 122 | os.environ.update(vars) 123 | 124 | def load_into_env(self): 125 | self._put_lines_to_vars(into="env") 126 | 127 | def load_into_provider(self): 128 | self._put_lines_to_vars(into="in") 129 | 130 | def get(self, name: str) -> str: 131 | if self._auto_load and not self._loaded_into: 132 | self.load_into_provider() 133 | 134 | if not self._loaded_into: 135 | raise BetterconfError("You haven't loaded values from .env manually") 136 | 137 | if self._loaded_into == "in": 138 | value = self._inner.get(name) 139 | if value is None: 140 | raise VariableNotFoundError(name) 141 | return value 142 | else: 143 | return self._environ.get(name) 144 | 145 | 146 | DEFAULT_PROVIDER = EnvironmentProvider() 147 | -------------------------------------------------------------------------------- /betterconf/_config.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from betterconf.provider import AbstractProvider 3 | from betterconf._field import _NO_DEFAULT, _Field as Field # type: ignore 4 | from betterconf._specials import is_special, AliasSpecial 5 | from betterconf.caster import BUILTIN_CASTERS 6 | from dataclasses import dataclass 7 | from betterconf.exceptions import BetterconfError 8 | 9 | FT = typing.TypeVar("FT") 10 | 11 | 12 | @dataclass 13 | class Prefix: 14 | prefix: str 15 | delimiter: str = "_" 16 | 17 | @staticmethod 18 | def process_name(name: str, prefix: typing.Optional["Prefix"] = None) -> str: 19 | if not prefix: 20 | return name 21 | 22 | return f"{prefix.prefix}{prefix.delimiter}{name}" 23 | 24 | 25 | class ConfigProto(typing.Protocol): 26 | __bc_subconfig__: typing.ClassVar[bool] 27 | __bc_inner__: typing.ClassVar["ConfigInner"] 28 | __bc_prefix__: typing.ClassVar[typing.Optional[Prefix]] 29 | __bc_provider__: typing.ClassVar[typing.Optional[AbstractProvider]] 30 | 31 | 32 | @dataclass 33 | class FieldInfo(typing.Generic[FT]): 34 | name_in_python: str 35 | field: Field[FT] 36 | 37 | @classmethod 38 | def parse_into( 39 | cls, 40 | src: type, 41 | name: str, 42 | annotation: FT, 43 | provider: typing.Optional[AbstractProvider] = None, 44 | prefix: typing.Optional[Prefix] = None, 45 | ) -> typing.Self: 46 | # for types like int, str, etc AND not initialized with `field` 47 | if annotation in BUILTIN_CASTERS and name not in src.__dict__: 48 | caster = BUILTIN_CASTERS[annotation] 49 | name_in_field = Prefix.process_name(name, prefix) 50 | return cls( 51 | name_in_python=name, 52 | field=Field(name=name_in_field, caster=caster, provider=provider), 53 | ) 54 | 55 | # if it is our special annotations 56 | elif is_special(annotation): 57 | default = getattr(src, name, _NO_DEFAULT) 58 | 59 | if isinstance(annotation, AliasSpecial): 60 | name_in_field = Prefix.process_name(annotation.alias, prefix) 61 | 62 | field_info = cls.parse_into( 63 | src, annotation.alias, annotation.tp, provider 64 | ) 65 | field_info.name_in_python = name 66 | if isinstance(default, Field): 67 | # var: Alias[str, "VAR"] = field(...) 68 | field_info.field = default 69 | 70 | elif default is not _NO_DEFAULT: 71 | field_info.field.default = default 72 | 73 | field_info.field.name = name_in_field 74 | 75 | if not field_info.field.provider: 76 | field_info.field.provider = provider 77 | 78 | return field_info 79 | 80 | elif ( 81 | annotation in BUILTIN_CASTERS 82 | and name in src.__dict__ 83 | and isinstance(getattr(src, name), Field) 84 | ): 85 | field = typing.cast(Field[FT], getattr(src, name)) 86 | field.caster = BUILTIN_CASTERS[annotation] 87 | 88 | if not field.provider: 89 | field.provider = provider 90 | if not field.name: 91 | field.name = name 92 | 93 | return cls(name_in_python=name, field=field) 94 | elif ( 95 | annotation not in BUILTIN_CASTERS 96 | and name in src.__dict__ 97 | and isinstance(getattr(src, name), Field) 98 | ): 99 | field: Field[FT] = getattr(src, name) 100 | if not field.provider: 101 | field.provider = provider 102 | if not field.name: 103 | field.name = name 104 | 105 | return cls(name_in_python=name, field=field) 106 | 107 | elif ( 108 | annotation in BUILTIN_CASTERS 109 | and name in src.__dict__ 110 | and isinstance(getattr(src, name), annotation) 111 | ): 112 | name_in_field = Prefix.process_name(name, prefix) 113 | val: typing.Any = getattr(src, name) 114 | field = Field(name=name_in_field, default=val, provider=provider) 115 | return cls(name_in_python=name, field=field) 116 | 117 | elif ( 118 | annotation in BUILTIN_CASTERS 119 | and name in src.__dict__ 120 | and not isinstance(getattr(src, name), annotation) 121 | ): 122 | raise BetterconfError( 123 | f"You try to set the value {repr(getattr(src, name))} for the field with name '{name}', that has type {annotation}.\nThe type {type(getattr(src, name))} is not assignable to type {annotation}" 124 | ) 125 | 126 | raise BetterconfError( 127 | "Something bad happened.\nBetterconf can't deal with this kind of value.\nProbably you've tried to use something like 'dict' in a constant manner. For this special case use 'constant_field`" 128 | ) 129 | 130 | 131 | @dataclass 132 | class SubConfigInfo: 133 | name: str 134 | cfg: typing.Type[ConfigProto] 135 | 136 | @classmethod 137 | def parse_into( 138 | cls, 139 | src: typing.Type[ConfigProto], 140 | provider: typing.Optional[AbstractProvider] = None, 141 | prefix: typing.Optional[Prefix] = None, 142 | ) -> typing.Self: 143 | if not src.__bc_provider__: 144 | src.__bc_provider__ = provider 145 | 146 | inner = ConfigInner.parse_into(src, provider, prefix) 147 | src.__bc_inner__ = inner 148 | return cls(name=src.__name__, cfg=src) 149 | 150 | 151 | @dataclass 152 | class ConfigInner: 153 | fields: typing.List[FieldInfo[typing.Any]] 154 | sub_configs: typing.List[SubConfigInfo] 155 | 156 | @classmethod 157 | def parse_into( 158 | cls, 159 | cfg: typing.Type[ConfigProto], 160 | provider: typing.Optional[AbstractProvider] = None, 161 | prefix: typing.Optional[Prefix] = None, 162 | ) -> typing.Self: 163 | try: 164 | annotations = typing.get_type_hints(cfg, include_extras=True) 165 | except TypeError: 166 | annotations = {} 167 | 168 | fields_info: typing.List[FieldInfo[typing.Any]] = [] 169 | for name, annotation in annotations.items(): 170 | parsed = FieldInfo[typing.Any].parse_into( 171 | cfg, name, annotation, provider, prefix 172 | ) 173 | fields_info.append(parsed) 174 | 175 | sub_configs: typing.List[SubConfigInfo] = [] 176 | 177 | for name, element in cfg.__dict__.items(): 178 | if getattr(element, "__bc_subconfig__", False): 179 | parsed = SubConfigInfo.parse_into(element, provider, prefix) 180 | sub_configs.append(parsed) 181 | elif isinstance(element, Field): 182 | if element.name is None: 183 | element.name = name 184 | 185 | fields_info.append( 186 | FieldInfo(name, typing.cast(Field[typing.Any], element)) 187 | ) 188 | 189 | return cls(fields_info, sub_configs) 190 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from typing import Any 5 | 6 | from betterconf import betterconf 7 | from betterconf import field, Prefix, reference_field, value, constant_field 8 | from betterconf._field import _Field # type: ignore 9 | from betterconf._specials import Alias 10 | from betterconf.provider import AbstractProvider 11 | from betterconf.caster import ( 12 | AbstractCaster, 13 | ConstantCaster, 14 | IntCaster, 15 | FloatCaster, 16 | ListCaster, 17 | ) 18 | from betterconf.caster import to_bool, to_int 19 | from betterconf.exceptions import VariableNotFoundError 20 | from betterconf.exceptions import ImpossibleToCastError 21 | 22 | VAR_1 = "hello" 23 | VAR_1_VALUE = "hello!#" 24 | 25 | @betterconf 26 | class TestConfig: 27 | debug = field("DEBUG", default=False, caster=to_bool) 28 | 29 | @betterconf(subconfig=True) 30 | class Sub1Config: 31 | @betterconf(subconfig=True) 32 | class Sub2Config: 33 | config_1 = field(default="test.mail.com") 34 | config_2 = field(default="465") 35 | config_3 = field(default="test") 36 | 37 | @betterconf(prefix=Prefix("PROD")) 38 | class ProdConfig(TestConfig): 39 | 40 | @betterconf(subconfig=True) 41 | class Sub1Config(TestConfig.Sub1Config): 42 | @betterconf(subconfig=True) 43 | class Sub2Config(TestConfig.Sub1Config.Sub2Config): 44 | config_1 = field(default="prod.mail.com") 45 | config_2 = field(default="100202") 46 | 47 | 48 | @pytest.fixture 49 | def update_environ(): 50 | os.environ["DEBUG"] = "true" 51 | os.environ["SUB1CONFIG_SUB2CONFIG_CONFIG_1"] = "test.mail.com" 52 | os.environ["PROD_SUB1CONFIG_SUB2CONFIG_CONFIG_2"] = "100202" 53 | os.environ["NONEFUL_FIELD"] = "None" 54 | os.environ["FALSY_FIELD"] = "0" 55 | yield 56 | os.environ.pop("DEBUG", None) 57 | os.environ.pop("SUB1CONFIG_SUB2CONFIG_CONFIG_1", None) 58 | os.environ.pop("PROD_SUB1CONFIG_SUB2CONFIG_CONFIG_2", None) 59 | 60 | 61 | def test_negative_values(update_environ: Any): 62 | caster = ConstantCaster[None]() 63 | caster.ABLE_TO_CAST = {"none": None} 64 | 65 | @betterconf 66 | class NegativeConfig: 67 | none = field("NONEFUL_FIELD", caster=caster) 68 | false = field("FALSY_FIELD", caster=to_int) 69 | 70 | cfg = NegativeConfig() 71 | assert cfg.none is None 72 | assert not cfg.false 73 | 74 | 75 | def test_not_exist(): 76 | @betterconf 77 | class ConfigBad: 78 | var1 = field(VAR_1) 79 | 80 | with pytest.raises(VariableNotFoundError): 81 | ConfigBad() 82 | 83 | 84 | def test_reference_field(): 85 | @betterconf 86 | class ConfigWithRef2: 87 | var1: dict[str, str] = field("var1", default=lambda: {"hello": "world"}) 88 | var2 = reference_field(var1, func=lambda v: v["hello"]) 89 | 90 | cfg = ConfigWithRef2() 91 | assert cfg.var2 == "world" 92 | 93 | 94 | def test_compose_field(): 95 | @betterconf 96 | class ConfigWithCompose: 97 | var1 = field("var1", default="John") 98 | var2 = reference_field( 99 | field("var2", default="hello"), 100 | var1, 101 | func=lambda first, second: f"{first} {second}", 102 | ) 103 | 104 | cfg = ConfigWithCompose() 105 | assert cfg.var2 == "hello John" 106 | 107 | 108 | def test_experimental_basics(update_environ: Any): 109 | @betterconf 110 | class Config: 111 | # caster type, alias 112 | debug: Alias[bool, "DEBUG"] 113 | value: str = field(default="LOL") 114 | meta = field(default=123) 115 | 116 | cfg = Config() 117 | assert cfg.debug is True 118 | assert cfg.value is not "lol" 119 | assert cfg.meta is 123 120 | 121 | 122 | def test_json_provider(): 123 | from betterconf.provider import JSONProvider 124 | import json 125 | 126 | data = json.dumps( 127 | {"DEBUG": True, "name": "Ilaja", "age": 15, "nested": {"status": True, }} 128 | ) 129 | 130 | @betterconf( 131 | provider=JSONProvider.from_string(data, nested_access="::"), 132 | ) 133 | class Config: 134 | debug: Alias[bool, "DEBUG"] 135 | nested_status: Alias[bool, "nested::status"] 136 | name: str 137 | age: int 138 | 139 | cfg = Config() 140 | assert cfg.debug is True 141 | assert cfg.name == "Ilaja" 142 | assert cfg.age > 10 143 | 144 | 145 | def test_experimental_subconfigs(update_environ: Any): 146 | @betterconf 147 | class Config: 148 | f = constant_field("ffff") 149 | 150 | @betterconf(subconfig=True) 151 | class Sub: 152 | debug: Alias[bool, "DEBUG"] 153 | 154 | cfg = Config() 155 | assert cfg.f == "ffff" 156 | assert cfg.Sub.debug is True 157 | 158 | 159 | def test_reference_to_override(): 160 | @betterconf 161 | class ConfigWithReference: 162 | var1: int = field("var1", default=4) 163 | var2: int = reference_field(var1, func=lambda v: v * 2) 164 | 165 | cfg1 = ConfigWithReference() 166 | assert cfg1.var2 == cfg1.var1 * 2 167 | 168 | cfg2 = ConfigWithReference(var1=15) 169 | assert cfg2.var2 == cfg2.var1 * 2 170 | 171 | 172 | def test_default_provider_for_cfg(): 173 | class FancyProvider(AbstractProvider): 174 | def get(self, name: str) -> str: 175 | return f"fancy_{name}" 176 | 177 | class SubFancyProvider(AbstractProvider): 178 | 179 | def get(self, name: str) -> str: 180 | return f"subfancy_{name}" 181 | 182 | @betterconf(provider=FancyProvider()) 183 | class MyConfig: 184 | 185 | val: str = field("value") 186 | 187 | @betterconf(subconfig=True, provider=SubFancyProvider()) 188 | class SubConfig: 189 | 190 | subval: str = field("value") 191 | 192 | @betterconf(subconfig=True) 193 | class SubConfigWithoutProvider: 194 | val: str = field("value") 195 | 196 | cfg = MyConfig() 197 | assert cfg.val == "fancy_value" 198 | assert cfg.SubConfig.subval == "subfancy_value" 199 | assert cfg.SubConfigWithoutProvider.val == "fancy_value" 200 | 201 | 202 | def test_instant_value(update_environ: Any): 203 | v: bool = value("DEBUG", caster=to_bool) 204 | assert v is True 205 | 206 | 207 | def test_reference_many_fields(): 208 | @betterconf 209 | class ConfigWithManyReferences: 210 | var1: int = field("var1", default=4) 211 | var2: int = field("var2", default=5) 212 | var3: int = field("var3", default=6) 213 | var4: int = reference_field( 214 | var1, var2, var3, func=lambda v1, v2, v3: v1 * 2 + v2 * 2 + v3 * 3 215 | ) 216 | 217 | cfg = ConfigWithManyReferences() 218 | assert cfg.var4 == (cfg.var1 * 2 + cfg.var2 * 2 + cfg.var3 * 3) 219 | 220 | 221 | def test_field_as_default(): 222 | @betterconf 223 | class ConfigWithDefaults: 224 | var1 = field(default="Goyda") 225 | var2 = field(default=var1) 226 | 227 | cfg = ConfigWithDefaults() 228 | assert cfg.var1 == "Goyda" 229 | assert cfg.var2 == "Goyda" 230 | 231 | cfg = ConfigWithDefaults(var1="hmm") 232 | assert cfg.var1 == "hmm" 233 | assert cfg.var2 == "hmm" 234 | 235 | 236 | def test_exist(): 237 | os.environ[VAR_1] = VAR_1_VALUE 238 | 239 | @betterconf 240 | class ConfigGood: 241 | var1 = field(VAR_1) 242 | 243 | cfg = ConfigGood() 244 | assert cfg.var1 == VAR_1_VALUE 245 | 246 | 247 | def test_default(): 248 | @betterconf 249 | class ConfigDefault: 250 | var1 = field("var_1", default="var_1 value") 251 | var2 = field("var_2", default=lambda: "callable var_2") 252 | 253 | cfg = ConfigDefault() 254 | assert cfg.var1 == "var_1 value" 255 | assert cfg.var2 == "callable var_2" 256 | 257 | 258 | def test_override(): 259 | @betterconf 260 | class ConfigOverride: 261 | var1 = field("var_1", default=1) 262 | 263 | cfg = ConfigOverride(var1=100000) 264 | assert cfg.var1 == 100000 265 | 266 | 267 | def test_own_provider(): 268 | class MyProvider(AbstractProvider): 269 | def get(self, name: str): 270 | return name # just return name of field =) 271 | 272 | provider = MyProvider() 273 | 274 | @betterconf 275 | class ConfigWithMyProvider: 276 | var1 = field("var_1", provider=provider) 277 | 278 | cfg = ConfigWithMyProvider() 279 | assert cfg.var1 == "var_1" 280 | 281 | 282 | def test_bundled_casters(): 283 | os.environ["boolean"] = "true" 284 | os.environ["integer"] = "-543" 285 | 286 | @betterconf 287 | class MyConfig: 288 | boolean = field("boolean", caster=to_bool) 289 | integer = field("integer", caster=to_int) 290 | 291 | cfg = MyConfig() 292 | assert cfg.boolean is True 293 | assert cfg.integer == -543 294 | 295 | 296 | def test_own_caster(): 297 | os.environ["text-with-dashes"] = "text-with-dashes" 298 | 299 | class DashToDotCaster(AbstractCaster): 300 | def cast(self, val: str): 301 | val = val.replace("-", ".") 302 | return val 303 | 304 | to_dot = DashToDotCaster() 305 | 306 | @betterconf 307 | class MyConfig: 308 | text = field("text-with-dashes", caster=to_dot) 309 | 310 | cfg = MyConfig() 311 | assert cfg.text == "text.with.dashes" 312 | 313 | 314 | def test_default_name_field(update_environ: Any): 315 | test_config = TestConfig() 316 | prod_config = ProdConfig() 317 | 318 | assert test_config.debug is True 319 | assert test_config.Sub1Config.Sub2Config.config_1 == "test.mail.com" 320 | assert prod_config.Sub1Config.Sub2Config.config_2 == "100202" 321 | 322 | 323 | def test_required_fields(): 324 | @betterconf 325 | class BaseConfig: 326 | config_1 = field() 327 | 328 | with pytest.raises(VariableNotFoundError): 329 | BaseConfig() 330 | 331 | 332 | def test_fiend_name_is_none(): 333 | with pytest.raises(VariableNotFoundError): 334 | _Field().value() # type: ignore 335 | 336 | 337 | def test_raise_abstract_provider(): 338 | with pytest.raises(NotImplementedError): 339 | AbstractProvider().get("test") 340 | 341 | 342 | def test_raise_abstract_caster(): 343 | with pytest.raises(NotImplementedError): 344 | AbstractCaster().cast("test") 345 | 346 | 347 | def test_constant_caster(): 348 | constant_caster = ConstantCaster[str]() 349 | 350 | constant_caster.ABLE_TO_CAST = {("key_1", "key_2"): "test"} 351 | assert constant_caster.cast("key_2") == "test" 352 | 353 | constant_caster.ABLE_TO_CAST = {"key_1": "test"} 354 | assert constant_caster.cast("Key_1") == "test" 355 | 356 | with pytest.raises(ImpossibleToCastError): 357 | assert constant_caster.cast("key") 358 | 359 | 360 | def test_raises_int_caster(): 361 | int_caster = IntCaster() 362 | with pytest.raises(ImpossibleToCastError): 363 | int_caster.cast("test") 364 | 365 | 366 | def test_float_caster(): 367 | float_caster = FloatCaster() 368 | assert float_caster.cast("3.14") == 3.14 369 | assert float_caster.cast("3,14") == 3.14 370 | 371 | with pytest.raises(ImpossibleToCastError): 372 | float_caster.cast("test") 373 | 374 | 375 | def test_list_caster(): 376 | list_caster = ListCaster() 377 | assert list_caster.cast("a") == ["a"] 378 | assert list_caster.cast("a,b,c") == ["a", "b", "c"] 379 | 380 | list_caster.separator = ";" 381 | assert list_caster.cast("a;b;c;") == ["a", "b", "c"] 382 | 383 | list_caster.separator = ", " 384 | assert list_caster.cast("a, b, c") == ["a", "b", "c"] 385 | assert list_caster.cast("a, b, c, ") == ["a", "b", "c"] 386 | --------------------------------------------------------------------------------