├── .coveragerc ├── .github └── workflows │ ├── deploy_pypi.yml │ └── unit_test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── pyproject.toml ├── requirements.txt ├── setup.py ├── test ├── __init__.py ├── test_casts.py ├── test_config_source.py ├── test_configuration.py └── test_version.py └── typedconfig ├── __init__.py ├── __version__.py ├── casts.py ├── config.py ├── provider.py ├── py.typed └── source.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = typedconfig 4 | 5 | [report] 6 | exclude_lines = 7 | if self.debug: 8 | pragma: no cover 9 | raise NotImplementedError 10 | if __name__ == .__main__.: 11 | @overload 12 | ignore_errors = True 13 | omit = 14 | tests/* 15 | -------------------------------------------------------------------------------- /.github/workflows/deploy_pypi.yml: -------------------------------------------------------------------------------- 1 | name: Release to PyPi 2 | 3 | on: 4 | push: 5 | tags: 6 | # See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet 7 | - v[0-9]+.[0-9]+.[0-9]+ 8 | 9 | jobs: 10 | unit-test: 11 | uses: ./.github/workflows/unit_test.yml 12 | secrets: inherit 13 | release-build: 14 | runs-on: ubuntu-latest 15 | needs: 16 | - unit-test 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.x" 22 | - name: Build release distributions 23 | run: | 24 | pip install twine setuptools wheel 25 | python setup.py sdist bdist_wheel 26 | twine check dist/* 27 | - name: Store dists as artifacts 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: release-dists 31 | path: dist/ 32 | pypi-publish: 33 | runs-on: ubuntu-latest 34 | environment: release 35 | needs: 36 | - release-build 37 | permissions: 38 | id-token: write 39 | steps: 40 | - name: Retrieve release artifacts 41 | uses: actions/download-artifact@v4 42 | with: 43 | name: release-dists 44 | path: dist/ 45 | - name: Publish release distributions to PyPI 46 | uses: pypa/gh-action-pypi-publish@release/v1 47 | -------------------------------------------------------------------------------- /.github/workflows/unit_test.yml: -------------------------------------------------------------------------------- 1 | name: Run unit tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | workflow_call: {} 8 | 9 | jobs: 10 | unit-test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: 15 | - '3.8' 16 | - '3.9' 17 | - '3.10' 18 | - '3.11' 19 | - '3.12' 20 | name: Python ${{ matrix.python-version }} unit test 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Setup python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install requirements 28 | run: pip install -r requirements.txt 29 | - name: Check typing 30 | run: mypy typedconfig --strict 31 | - name: Check code formatting 32 | run: black typedconfig test --check 33 | - name: Run unit tests 34 | run: pytest --cov 35 | - name: Upload code coverage 36 | run: codecov 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | __pycache__ 3 | venv 4 | .coverage 5 | build 6 | dist 7 | typed_config.egg-info 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### v0.1.1 2 | Bugfix for when multiple `group_key`s are used within a composite config. 3 | 4 | ### v0.1.0 5 | Initial release 6 | 7 | ### v0.1.2 8 | Read properties in parallel for faster config loading. This does require the `get_config_value` method of any `ConfigSource` object to be thread safe. 9 | Fix types for `cast` property of `key`. 10 | 11 | ### v0.1.3 12 | Revert changes from 0.1.2. Parallel was unhelpful. 13 | 14 | ### v0.1.4 15 | Add some logging 16 | Add `__repr__` method to `ConfigSource` 17 | 18 | ### v0.1.5 19 | Add more `__repr__` methods in `ConfigSource` 20 | 21 | ### v0.1.6 22 | Fix: don't cast default value 23 | 24 | ### v0.2.0 25 | Refactor to use a `ConfigProvider` which can be shared as a provider between all `Config` objects 26 | 27 | ### v0.2.1 28 | Add `replace_source` and `set_sources` methods on `Config` and `ConfigProvider` for easier manipulation of sources 29 | 30 | ### v0.2.2 31 | * Fix typing on `@section` decorator. This was previously preventing IDE autocompletion. The return type of the decorator now uses a generic to indicate that it returns the same type as it is passed. This means that whether or not it is passed a `Config` subclass is no longer type checked, but it means that the returned class has all the correct properties on it, which is more important. 32 | * Add basic `repr` function to `Config` 33 | 34 | ### v0.2.3 35 | Version bump to build after move from travis-ci.org to travis-ci.com 36 | 37 | ### v0.2.4 38 | Version bump to build after move from travis-ci.org to travis-ci.com 39 | 40 | ### v0.2.5 41 | Version bump to build after move from travis-ci.org to travis-ci.com 42 | 43 | ### v0.3.0 44 | Add `post_read_hook` method which can be added to `Config` subclasses to allow parts of the config to be modified after it has been fully loaded 45 | 46 | ### v1.0.0 47 | BREAKING CHANGE: The `key` function now expects keyword arguments and will not accept non-keyword arguments any more. 48 | Make compatible with mypy. 49 | 50 | ### v1.0.1 51 | Remove `include_package_data=True` from `setup.py` to ensure `py.typed` is included in source distributions 52 | 53 | ### v1.0.3 54 | Make type annotations pass string Mypy checking. 55 | 56 | ### v1.1.0 57 | Remove pointless casting of `default` value, since the `default` should be provided already cast, and should have the same type as the return value of the `cast` function. The also means that the type of a custom `cast` function should only need to accept a string as input, rather than having to check for if it has already been cast. 58 | 59 | ### v1.2.0 60 | Fix `ImportError` for `typing_extensions` on Python <= 3.7, by requiring it as a dependency. This is because `Literal` is used as a type. This did not become part of the built in `typing` module until Python 3.8. 61 | 62 | ### v1.2.1 63 | Enable CI testing on Python 3.10 and 3.11. Use `sys.version_info` to help with imports as mypy plays nicely with this. 64 | 65 | ### v1.3.0 66 | Drop running tests on Python 3.6 since it is end of life. 67 | 68 | ### v1.3.1 69 | As part of a solution for issue #12, add cast submodule with enum_cast helper function 70 | 71 | ### v1.3.2 72 | Add tuple_cast to the the cast submodule and document it 73 | 74 | ### v1.4.0 75 | Add `boolean_cast` and `optional_boolean_cast` to the cast submodule and document them 76 | 77 | ### v1.5.0 78 | Add `CmdConfigSource` and documentation 79 | 80 | ### v2.0.0 81 | Drop support for Python 3.7 since it is end of life. 82 | 83 | ### v2.0.1, 2.0.2, 2.0.3 84 | No user facing changes, fixing CI release process. 85 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ben Windsor 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build Status](https://github.com/bwindsor/typed-config/actions/workflows/unit_test.yml/badge.svg) 2 | ![Deploy Status](https://github.com/bwindsor/typed-config/actions/workflows/deploy_pypi.yml/badge.svg) 3 | [![codecov](https://codecov.io/gh/bwindsor/typed-config/branch/master/graph/badge.svg)](https://codecov.io/gh/bwindsor/typed-config) 4 | 5 | # typed-config 6 | Typed, extensible, dependency free configuration reader for Python projects for multiple config sources and working well in IDEs for great autocomplete performance. 7 | 8 | `pip install typed-config` 9 | 10 | Requires python 3.6 or above. (Also, needs `typing_extensions` for Python <= 3.7, so not completely dependency free if using old Python). 11 | 12 | ## Basic usage 13 | ```python 14 | # my_app/config.py 15 | from typedconfig import Config, key, section 16 | from typedconfig.source import EnvironmentConfigSource 17 | 18 | @section('database') 19 | class AppConfig(Config): 20 | host = key(cast=str) 21 | port = key(cast=int) 22 | timeout = key(cast=float) 23 | 24 | config = AppConfig() 25 | config.add_source(EnvironmentConfigSource()) 26 | config.read() 27 | ``` 28 | 29 | ```python 30 | # my_app/main.py 31 | from my_app.config import config 32 | print(config.host) 33 | ``` 34 | In PyCharm, and hopefully other IDEs, it will recognise the datatypes of your configuration and allow you to autocomplete. No more remembering strings to get the right thing out! 35 | 36 | ## Upgrading 37 | 38 | ### From 0.x.x 39 | There is one breaking change when moving from `0.x.x` to `1.x.x`. The `key` function now expects all arguments to be keyword arguments. So simply replace any calls like so: 40 | ```python 41 | key('section', 'key', True, str, 'default') # 0.x.x 42 | key(section_name='section', key_name='key', required=True, cast=str, default='default') # 1.x.x 43 | ``` 44 | The reason for this change is to tie down the return type of `key` properly. Previously, when `required=False` or when `cast=None` the return type would not include the possibility of `None` or `string`. The type checker should now be able to infer the return type based on the values of `required`, `cast` and `default`. 45 | 46 | ### From 1.x.x 47 | When upgrading from `1.x.x` to `2.x.x`, Python 3.7 no longer supported as it is end of life. As long as you are using Python >= 3.8, everything should just work. 48 | 49 | ## How it works 50 | Configuration is always supplied in a two level structure, so your source configuration can have multiple sections, and each section contains multiple key/value configuration pairs. For example: 51 | ```ini 52 | [database] 53 | host = 127.0.0.1 54 | port = 2000 55 | 56 | [algorithm] 57 | max_value = 10 58 | min_value = 20 59 | ``` 60 | 61 | You then create your configuration hierarchy in code (this can be flat or many levels deep) and supply the matching between strings in your config sources and properties of your configuration classes. 62 | 63 | You provide one or more `ConfigSource`s, from which the config for your application can be read. For example, you might supply an `EnvironmentConfigSource`, and two `IniFileConfigSource`s. This would make your application first look for a configuration value in environment variables, if not found there it would then look at the first INI file (perhaps a user-specific file), before falling back to the second INI file (perhaps a default configuration shared between all users). If a parameter is still not found and is a required parameter, an error would be thrown. 64 | 65 | There is emphasis on type information being available for everything so that an IDE will autocomplete when trying to use your config across your application. 66 | 67 | ### Multiple data sources 68 | ```python 69 | from typedconfig import Config, key, section, group_key 70 | from typedconfig.source import EnvironmentConfigSource, IniFileConfigSource 71 | 72 | @section('database') 73 | class DatabaseConfig(Config): 74 | host = key(cast=str) 75 | port = key(cast=int) 76 | username = key(cast=str) 77 | password = key(cast=str) 78 | 79 | config = DatabaseConfig() 80 | config.add_source(EnvironmentConfigSource(prefix="EXAMPLE")) 81 | config.add_source(IniFileConfigSource("config.cfg")) 82 | 83 | # OR provide sources directly to the constructor 84 | config = DatabaseConfig(sources=[ 85 | EnvironmentConfigSource(prefix="EXAMPLE"), 86 | IniFileConfigSource("config.cfg") 87 | ]) 88 | ``` 89 | 90 | Since you don't want to hard code your secret credentials, you might supply them through the environment. 91 | So for the above configuration, the environment might look like this: 92 | ```bash 93 | export EXAMPLE_DATABASE_USERNAME=my_username 94 | export EXAMPLE_DATABASE_PASSWORD=my_very_secret_password 95 | export EXAMPLE_DATABASE_PORT=2001 96 | ``` 97 | 98 | Those values which couldn't be found in the environment would then be read from the INI file, which might look like this: 99 | ```ini 100 | [database] 101 | HOST = db1.mydomain.com 102 | PORT = 2000 103 | ``` 104 | 105 | Note after this, `config.port` will be equal to `2001` as the value in the environment took priority over the value in the INI file. 106 | 107 | ### Caching 108 | When config values are first used, they are read. This is lazy evaluation by default so that not everything is read if not necessary. 109 | 110 | After first use, they are cached in memory so that there should be no further I/O if the config value is used again. 111 | 112 | For fail fast behaviour, and also to stop unexpected latency when a config value is read partway through your application (e.g. your config could be coming across a network), the option is available to read all config values at the start. Just call 113 | 114 | `config.read()` 115 | 116 | This will throw an exception if any required config value cannot be found, and will also keep all read config values in memory for next time they are used. If you do not use `read` you will only get the exception when you first try to use the offending config key. 117 | 118 | ### Hierarchical configuration 119 | Use `group_key` to represent a "sub-config" of a configuration. Set up "sub-configs" exactly as demonstrated above, and then create a parent config to compose them in one place. 120 | ```python 121 | from typedconfig import Config, key, section, group_key 122 | from typedconfig.source import EnvironmentConfigSource, IniFileConfigSource 123 | 124 | @section('database') 125 | class DatabaseConfig(Config): 126 | host = key(cast=str) 127 | port = key(cast=int) 128 | 129 | @section('algorithm') 130 | class AlgorithmConfig(Config): 131 | max_value = key(cast=float) 132 | min_value = key(cast=float) 133 | 134 | class ParentConfig(Config): 135 | database = group_key(DatabaseConfig) 136 | algorithm = group_key(AlgorithmConfig) 137 | description = key(cast=str, section_name="general") 138 | 139 | config = ParentConfig() 140 | config.add_source(EnvironmentConfigSource(prefix="EXAMPLE")) 141 | config.add_source(IniFileConfigSource("config.cfg")) 142 | config.read() 143 | ``` 144 | 145 | The first time the `config.database` or `config.algorithm` is accessed (which in the case above is when `read()` is called), then an instance will be instantiated. Notice that it is the class definition, not an instance of the class, which is passed to the `group_key` function. 146 | 147 | ### Custom section/key names, optional parameters, default values 148 | Let's take a look at this: 149 | ```python 150 | from typedconfig import Config, key, section 151 | 152 | @section('database') 153 | class AppConfig(Config): 154 | host1 = key() 155 | host2 = key(section_name='database', key_name='HOST2', 156 | required=True, cast=str, default=None) 157 | ``` 158 | Both `host1` and `host2` are legitimate configuration key definitions. 159 | 160 | * `section_name` - this name of the section in the configuration source from which this parameter should be read. This can be provided on a key-by-key basis, but if it is left out then the section name supplied by the `@section` decorator is used. If all keys supply a `section_name`, the class decorator is not needed. If both `section_name` and a decorator are provided, the `section_name` argument takes priority. 161 | * `key_name` - the name of this key in the configuration source from which this parameter is read. If not supplied, some magic uses the object property name as the key name. 162 | * `required` - default True. If False, and the configuration value can't be found, no error will be thrown and the default value will be used, if provided. If a default not provided, `None` will be used. 163 | * `cast` - probably the most important option for typing. **If you want autocomplete typing support you must specify this**. It's just a function which takes a string as an input and returns a parsed value. See the casting section for more. If not supplied, the value remains as a string. 164 | * `default` - only applicable if `required` is false. When `required` is false this value is used if a value cannot be found. 165 | 166 | ### Types 167 | ```python 168 | from typedconfig import Config, key, section 169 | from typing import List 170 | 171 | def split_str(s: str) -> List[str]: 172 | return [x.strip() for x in s.split(",")] 173 | 174 | @section('database') 175 | class AppConfig(Config): 176 | host = key() 177 | port = key(cast=int) 178 | users = key(cast=split_str) 179 | zero_based_index = key(cast=lambda x: int(x)-1) 180 | config = AppConfig(sources=[...]) 181 | ``` 182 | In this example we have three ways of casting: 183 | 1. Not casting at all. This defaults to returning a `str`, but your IDE won't know that so if you want type hints use `cast=str` 184 | 2. Casting to an built in type which can take a string input and parse it, for example `int` 185 | 3. Defining a custom function. Your function should take one string input and return one output of any type. To get type hint, just make sure your function has type annotations. 186 | 4. Using a lambda expression. The type inference may or may not work depending on your expression, so if it doesn't just write it as a function with type annotations. 187 | 188 | ### Validation 189 | You can validate what has been supplied by providing a custom `cast` function to a `key`, which validates the configuration value in addition to parsing it. 190 | 191 | ### Extending configuration using shared ConfigProvider 192 | 193 | Multiple application modules may use different configuration schemes while sharing the same configuration source. Analogously, various `Config` classes may provide different view of the same configuration data, sharing the same `ConfigProvider`. 194 | 195 | ```python 196 | # app/config.py 197 | from typedconfig.provider import ConfigProvider 198 | from typedconfig.source import EnvironmentConfigSource, IniFileConfigSource 199 | 200 | provider = ConfigProvider() 201 | provider.add_source(EnvironmentConfigSource(prefix="EXAMPLE")) 202 | provider.add_source(IniFileConfigSource("config.cfg")) 203 | 204 | __all__ = ["provider"] 205 | ``` 206 | ```python 207 | # app/database/config.py 208 | from typedconfig import Config, key, section 209 | from app.config import provider 210 | 211 | @section('database') 212 | class DatabaseConfig(Config): 213 | host = key(cast=str) 214 | port = key(cast=int) 215 | 216 | database_config = DatabaseConfig(provider=provider) 217 | ``` 218 | ```python 219 | # app/algorithm/config.py 220 | from typedconfig import Config, key, section 221 | from app.config import provider 222 | 223 | @section('algorithm') 224 | class AlgorithmConfig(Config): 225 | max_value = key(cast=float) 226 | min_value = key(cast=float) 227 | 228 | algorithm_config = AlgorithmConfig(provider=provider) 229 | ``` 230 | 231 | Shared configuration provider can be used by plugins, which may need to declare additional configuration sections within the same configuration files as the main application. Let's assume we have `[database]` section used by main application and `[app_extension]` that provides 3rd party plugin configuration: 232 | 233 | ```ini 234 | [database] 235 | host = 127.0.0.1 236 | port = 2000 237 | 238 | [app_extension] 239 | api_key = secret 240 | ``` 241 | 242 | e.g. `app/config.py` may look like that: 243 | 244 | ```python 245 | from typedconfig import Config, key, section, group_key 246 | from typedconfig.source import EnvironmentConfigSource, IniFileConfigSource 247 | 248 | @section('database') 249 | class DatabaseConfig(Config): 250 | """Database configuration""" 251 | host = key(cast=str) 252 | port = key(cast=int) 253 | 254 | class ApplicationConfig(Config): 255 | """Main configuration object""" 256 | database = group_key(DatabaseConfig) 257 | 258 | app_config = ApplicationConfig(sources=[ 259 | EnvironmentConfigSource(), 260 | IniFileConfigSource("config.cfg") 261 | ]) 262 | ``` 263 | 264 | and plugin can read additional sections by using the same configuration provider as main application config. 265 | 266 | e.g. `plugin/config.py`: 267 | ```python 268 | from typedconfig import Config, key, section, group_key 269 | 270 | from app.config import ApplicationConfig, app_config 271 | 272 | @section('app_extension') 273 | class ExtensionConfig(Config): 274 | """Extension configuration""" 275 | api_key = key(cast=str) 276 | 277 | # ExtendedAppConfig extends ApplicationConfig 278 | # so original sections are also included 279 | class ExtendedAppConfig(ApplicationConfig): 280 | """Extended main configuration object""" 281 | app_extension = group_key(ExtensionConfig) 282 | 283 | # ExtendedAppConfig uses the same provider as the main app_config 284 | extended_config = ExtendedAppConfig(provider=app_config.provider) 285 | ``` 286 | ```python 287 | from plugin.config import extended_config 288 | 289 | # Plugin can access both main and extra sections 290 | print(extended_config.app_extension.api_key) 291 | print(extended_config.database.host) 292 | ``` 293 | 294 | ### Configuration variables which depend on other configuration variables 295 | Sometimes you may wish to set the value of some configuration variables based on others. You may also wish to validate some variables, for example allowed values may be different depending on the value of another config variable. For this you can add a `post_read_hook`. 296 | 297 | The default implementation of `post_read_hook` returns an empty `dict`. You can override this by implementing your own `post_read_hook` method. It should receive only `self` as an input, and return a `dict`. This `dict` should be a simple mapping from config keys to values. For hierarchical configurations, you can nest the dictionaries. If you provide a `post_read_hook` in both a parent and a child class which both make changes to the same keys (don't do this) then the values returned by the child method will overwrite those by the parent. 298 | 299 | This hook is called whenever you call the `read` method. If you use lazy loading and skip calling the `read` method, you cannot use this hook. 300 | ```python 301 | # my_app/config.py 302 | from typedconfig import Config, key, group_key, section 303 | from typedconfig.source import EnvironmentConfigSource 304 | 305 | @section('child') 306 | class ChildConfig(Config): 307 | http_port_plus_one = key(cast=int, required=False) 308 | 309 | @section('app') 310 | class AppConfig(Config): 311 | use_https = key(cast=bool) 312 | http_port = key(key_name='port', cast=int, required=False) 313 | child = group_key(ChildConfig) 314 | 315 | def post_read_hook(self) -> dict: 316 | config_updates = dict() 317 | # If the port has not been provided, set it based on the value of use_https 318 | if self.http_port is None: 319 | config_updates.update(http_port=443 if self.use_https else 80) 320 | else: 321 | # Modify child config 322 | config_updates.update(child=dict(http_port_plus_one=self.http_port + 1)) 323 | 324 | # Validate that the port number has a sensible value 325 | # It is recommended to do validation inside the cast method for individual keys, however for dependent keys it can be useful here 326 | if self.http_port is not None: 327 | if self.use_https: 328 | assert self.http_port in [443, 444, 445] 329 | else: 330 | assert self.http_port in [80, 81, 82] 331 | 332 | return config_updates 333 | 334 | config = AppConfig() 335 | config.add_source(EnvironmentConfigSource()) 336 | config.read() 337 | ``` 338 | 339 | ## Configuration Sources 340 | Configuration sources are how your main `Config` class knows where to get its data from. These are totally extensible so that you can read in your configuration from wherever you like - from a database, from S3, anywhere that you can write code for. 341 | 342 | You supply your configuration source to your config after you've instantiated it, but **before** you try to read any data from it: 343 | ```python 344 | config = AppConfig() 345 | config.add_source(my_first_source) 346 | config.add_source(my_second_source) 347 | config.read() 348 | ``` 349 | Or you can supply the sources directly in the constructor like this: 350 | ```python 351 | config = AppConfig(sources=[my_first_source, my_second_source]) 352 | config.read() 353 | ``` 354 | 355 | 356 | ### Modifying or refreshing configuration after it has been loaded 357 | In general it is bad practice to modify configuration at runtime because the configuration for your program should be fixed for the duration of it. However, there are cases where it may be necessary. 358 | 359 | To completely replace the set of config sources, you can use 360 | ```python 361 | config = AppConfig(sources=[my_first_source, my_second_source]) 362 | config.set_sources([my_first_new_source, my_second_new_source]) 363 | ``` 364 | 365 | To replace a specific config source, for example because a config file has changed and you need to re-read it from disk, you can use `replace_source`: 366 | ```python 367 | from typedconfig.source import IniFileConfigSource 368 | original_source = IniFileConfigSource("config.cfg") 369 | config = AppConfig(sources=[source]) 370 | # Now say you change the contents to config.cfg and need to read it again 371 | new_source = IniFileConfigSource("config.cfg") # re-reads file during construction 372 | config.replace_source(original_source, new_source) 373 | ``` 374 | 375 | **Important**: if you add or modify the config sources the config has been read, or need to refresh the config for some reason, you'll need to clear any cached values in order to force the config to be fetched from the `ConfigSource`s again. You can do this by 376 | ```python 377 | config.clear_cache() 378 | config.read() # Read all configuration values again 379 | ``` 380 | 381 | 382 | 383 | ### Supplied Config Sources 384 | #### `EnvironmentConfigSource` 385 | This just reads configuration from environment variables. 386 | ```python 387 | from typedconfig.source import EnvironmentConfigSource 388 | source = EnvironmentConfigSource(prefix="XYZ") 389 | # OR just 390 | source = EnvironmentConfigSource() 391 | ``` 392 | It just takes one optional input argument, a prefix. This can be useful to avoid name clashes in environment variables. 393 | 394 | * If prefix is provided, environment variables are expected to look like `{PREFIX}_{SECTION}_{KEY}`, for example `export XYZ_DATABASE_PORT=2000`. 395 | * If no prefix is provided, environment variables should look like `{SECTION}_{KEY}`, for example `export DATABASE_PORT=2000`. 396 | 397 | #### `IniFileConfigSource` 398 | This reads from an INI file using Python's built in [configparser](https://docs.python.org/3/library/configparser.html). Read the docs for `configparser` for more about the structure of the file. 399 | ```python 400 | from typedconfig.source import IniFileConfigSource 401 | source = IniFileConfigSource("config.cfg", encoding='utf-8', must_exist=True) 402 | ``` 403 | 404 | * The first argument is the filename (absolute or relative to the current working directory). 405 | * `encoding` is the text encoding of the file. `configparser`'s default is used if not supplied. 406 | * `must_exist` - default `True`. If the file can't be found, an error will be thrown by default. Setting `must_exist` to be `False` allows the file not to be present, in which case this source will just report that it can't find any configuration values and your `Config` class will move onto looking in the next `ConfigSource`. 407 | 408 | #### `IniStringConfigSource` 409 | This reads from a string instead of a file 410 | ```python 411 | from typedconfig.source import IniStringConfigSource 412 | source = IniStringConfigSource(""" 413 | [section_name] 414 | key_name=key_value 415 | """) 416 | ``` 417 | 418 | #### `DictConfigSource` 419 | The most basic source, entirely in memory, and also useful when writing tests. It is case insensitive. 420 | ```python 421 | from typedconfig.source import DictConfigSource 422 | source = DictConfigSource({ 423 | 'database': dict(HOST='db1', PORT='2000'), 424 | 'algorithm': dict(MAX_VALUE='20', MIN_VALUE='10') 425 | }) 426 | ``` 427 | 428 | It expects data type `Dict[str, Dict[str, str]]`, i.e. such that `string_value = d['section_name']['key_name']`. Everything should be provided as string data so that it can be parsed in the same way as if data was coming from a file or elsewhere. 429 | 430 | This is an alternative way of supplying default values instead of using the `default` option when defining your `key`s. Just provide a `DictConfigSource` as the lowest priority source, containing your defaults. 431 | 432 | #### `CmdConfigSource` 433 | This reads configuration from command line arguments 434 | ```python 435 | from typedconfig.source import CmdConfigSource 436 | source = CmdConfigSource(prefix="XYZ") 437 | # OR just 438 | source = CmdConfigSource() 439 | ``` 440 | 441 | It will use Python's `argparse` library to extract the relevant command line arguments. It takes an optional input argument, the `prefix`. This can be useful if you also accept other command line arguments and wish to avoid clashes with your own arguments. 442 | 443 | * Arguments will be expected in the format `--{PREFIX}_{SECTION}_{KEY} {VALUE}`, for example `--xyz_database_port 2000` 444 | * If no `prefix` is provided, arguments will be expected in the format `--{SECTION}_{KEY} {VALUE}`, for example `--database_port 2000` 445 | 446 | The names are case insensitive so, for example, `--DATABASE_PORT` and `--database_port` would be treated the same. 447 | 448 | ### Writing your own `ConfigSource`s 449 | An abstract base class `ConfigSource` is supplied. You should extend it and implement the method `get_config_value` as demonstrated below, which takes a section name and key name, and returns either a `str` config value, or `None` if the value could not be found. It should not error if the value cannot be found, `Config` will throw an error later if it still can't find the value in any of its other available sources. To make it easier for the user try to make your source case insensitive. 450 | 451 | Here's an outline of how you might implement a source to read your config from a JSON file, for example. Use the `__init__` method to provide any information your source needs to fetch the data, such as filename, api details, etc. You can do sanity checks in the `__init__` method and throw an error if something is wrong. 452 | ```python 453 | import json 454 | from typing import Optional 455 | from typedconfig.source import ConfigSource 456 | 457 | class JsonConfigSource(ConfigSource): 458 | def __init__(self, filename: str): 459 | # Read data - will raise an exception if problem with file 460 | with open(filename, 'r') as f: 461 | self.data = json.load(f) 462 | # Quick checks on data format 463 | assert type(self.data) is dict 464 | for k, v in self.data.items(): 465 | assert type(k) is str 466 | assert type(v) is dict 467 | for v_k, v_v in v.items(): 468 | assert type(v_k) is str 469 | assert type(v_v) is str 470 | # Convert all keys to lowercase 471 | self.data = { 472 | k.lower(): { 473 | v_k.lower(): v_v 474 | for v_k, v_v in v.items() 475 | } 476 | for k, v in self.data.items() 477 | } 478 | 479 | def get_config_value(self, section_name: str, key_name: str) -> Optional[str]: 480 | # Extract info from data which we read in during __init__ 481 | section = self.data.get(section_name.lower(), None) 482 | if section is None: 483 | return None 484 | return section.get(key_name.lower(), None) 485 | ``` 486 | 487 | ### Additional config sources 488 | In order to keep `typed-config` dependency free, `ConfigSources` requiring additional dependencies are in separate packages, which also have `typed-config` as a dependency. 489 | 490 | These are listed here: 491 | 492 | | pip install name | import name | Description | 493 | | --- | --- | --- | 494 | | [typed-config-aws-sources](https://pypi.org/project/typed-config-aws-sources) | `typedconfig_awssource` | Config sources using `boto3` to get config e.g. from S3 or DynamoDB 495 | 496 | 497 | ## Cast function library 498 | The `typedconfig.casts` module contains helper functions that implement common casting operations. These would generally be passed to the `cast` parameter of the `key()` function 499 | 500 | ### Casting to an `Enum` type with `enum_cast` 501 | the `enum_cast` function converts a string input from a source to a member of an `Enum` type. 502 | 503 | For example: 504 | ```python 505 | from enum import Enum 506 | from typedconfig.casts import enum_cast 507 | from typedconfig import Config, key 508 | ... 509 | class ColorEnum(Enum): 510 | RED = 1 511 | GREEN = 2 512 | BLUE = 3 513 | ... 514 | 515 | class MyConfig(Config): 516 | color = key(cast=enum_cast(ColorEnum)) 517 | ``` 518 | 519 | In this example, if the `ConfigSource` reads the string `"RED"`, the value of `color` will be set to `ColorEnum.RED` 520 | 521 | Note that the `enum_cast` function is designed to read the *name* of an enum member, not its value (`1`, `2` or `3` in the example above) 522 | 523 | ### Casting to a `tuple` with `tuple_cast` 524 | If the source contains a list of items, `tuple_cast` will parse them as a python tuple, optionally applying a 525 | base_cast function to each element. 526 | The default behavior is to call strip() on each element and ignore trailing delimiters. 527 | 528 | For example, given the input 529 | ```ini 530 | nums = 1, 2, 3, 4, 531 | ``` 532 | 533 | Then you could use 534 | 535 | ```python 536 | nums = key(cast=tuple_cast()) 537 | ``` 538 | to read the string input and cast it to `("1", "2", "3", "4")` 539 | 540 | ```python 541 | key(cast=tuple_cast(base_cast=int)) 542 | ``` 543 | Would cast the input to `(1, 2, 3, 4)` 544 | 545 | ```python 546 | key(cast=tuple_cast(ignore_trailing_delimiter=False)) 547 | ``` 548 | Would cast the input to `("1", "2", "3", "4", "")` 549 | 550 | ```python 551 | key(cast=tuple_cast(strip=False)) 552 | ``` 553 | Would cast the input to `("1", " 2", " 3", " 4")` 554 | 555 | Finally, if a delimiter other that "," is used - say the input string is `"1:2:3:4"` - that can be handled like 556 | ```python 557 | key(cast=tuple_cast(delimiter=":")) 558 | ``` 559 | 560 | ### Casting to a `bool` with `boolean_cast` 561 | 562 | If the source is a boolean value, it can be converted to Python's `True` or `False` using this cast. 563 | 564 | ```python 565 | key(cast=boolean_cast) 566 | ``` 567 | 568 | `boolean_cast` supports the following values and is case-insensitive. Any other values will result in a `KeyError` while parsing. 569 | 570 | Value | Strings 571 | --- | --- 572 | `True` | `"true"`, `"yes"`, `"on"`, `"1"` 573 | `False` | `"false"`, `"no"`, `"off"`, `"0"` 574 | 575 | ### Casting to an `Optional[bool]` with `optional_boolean_cast` 576 | 577 | If the source is a boolean value which can also be `None`, it can be converted to Python's `True` or `False` or `None` using this cast. 578 | 579 | ```python 580 | key(cast=optional_boolean_cast) 581 | ``` 582 | 583 | `optional_boolean_cast` supports the following values and is case-insensitive. Any other values will result in a `KeyError` while parsing. 584 | 585 | Value | Strings 586 | --- | --- 587 | `True` | `"true"`, `"yes"`, `"on"`, `"1"` 588 | `False` | `"false"`, `"no"`, `"off"`, `"0"` 589 | `None` | `"none"`, `"unknown"` 590 | 591 | 592 | ## Contributing 593 | Ideas for new features and pull requests are welcome. PRs must come with tests included. This is developed using Python 3.9 but Github actions will run tests with all versions 3.8-3.12 too. 594 | 595 | ### Development setup 596 | 1. Clone the git repository 597 | 2. Create a virtual environment `virtualenv venv` 598 | 3. Activate the environment `venv/scripts/activate` 599 | 4. Install development dependencies `pip install -r requirements.txt` 600 | 601 | ### Code style 602 | Code style is `black` and this is checked by the CI. To autoformat your code as `black` before committing, just run `black typedconfig test` 603 | 604 | ### Running tests 605 | `pytest` 606 | 607 | To run with coverage: 608 | 609 | `pytest --cov` 610 | 611 | ### Making a release 612 | 1. Bump version number in `typedconfig/__version__.py` 613 | 1. Add changes to [CHANGELOG.md](CHANGELOG.md) 614 | 1. Commit your changes and tag with `git tag -a v0.1.0 -m "Summary of changes"` 615 | 1. Github actions will deploy the release to PyPi for you. 616 | 617 | #### Staging release 618 | If you want to check how a release will look on PyPi before tagging and making it live, you can do the following: 619 | 1. `pip install twine` if you don't already have it 620 | 1. Bump version number in `typedconfig/__version__.py` 621 | 1. Clear the dist directory `rm -r dist` 622 | 1. `python setup.py sdist bdist_wheel` 623 | 1. `twine check dist/*` 624 | 1. Upload to the test PyPI `twine upload --repository-url https://test.pypi.org/legacy/ dist/*` 625 | 1. Check all looks ok at [https://test.pypi.org/project/typed-config](https://test.pypi.org/project/typed-config) 626 | 1. If all looks good you can git tag and push for deploy to live PyPi 627 | 628 | Here is [a good tutorial](https://realpython.com/pypi-publish-python-package) on publishing packages to PyPI. 629 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov 3 | codecov 4 | mypy 5 | black 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from setuptools import setup 3 | from typedconfig.__version__ import __version__ 4 | 5 | # The text of the README file 6 | readme_text = Path(__file__).with_name("README.md").read_text() 7 | 8 | setup( 9 | name='typed-config', 10 | version=__version__, 11 | description='Typed, extensible, dependency free configuration reader for Python projects for multiple config sources and working well in IDEs for great autocomplete performance.', 12 | long_description=readme_text, 13 | long_description_content_type='text/markdown', 14 | url='https://github.com/bwindsor/typed-config', 15 | author='Ben Windsor', 16 | author_email='', 17 | python_requires='>=3.6.0', 18 | license='MIT', 19 | classifiers=[ 20 | 'Programming Language :: Python :: 3', 21 | 'Programming Language :: Python :: 3.8', 22 | 'Programming Language :: Python :: 3.9', 23 | 'Programming Language :: Python :: 3.10', 24 | 'Programming Language :: Python :: 3.11', 25 | 'Programming Language :: Python :: 3.12', 26 | 'Development Status :: 4 - Beta', 27 | 'Intended Audience :: Developers', 28 | 'License :: OSI Approved :: MIT License', 29 | 'Topic :: Software Development', 30 | 'Typing :: Typed', 31 | ], 32 | packages=['typedconfig'], 33 | package_data={'typedconfig': ['py.typed']}, 34 | entry_points={} 35 | ) 36 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwindsor/typed-config/b9df1ed8f21c3df9baa9b388267fdc31f7c6f361/test/__init__.py -------------------------------------------------------------------------------- /test/test_casts.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from enum import Enum 3 | from typing import Dict, Tuple, Any, Union 4 | from typedconfig.casts import enum_cast, tuple_cast, boolean_cast, optional_boolean_cast 5 | from contextlib import nullcontext 6 | 7 | 8 | class ExampleEnum(Enum): 9 | RED = 1 10 | GREEN = 2 11 | BLUE = 3 12 | 13 | 14 | def test_valid_enum_cast() -> None: 15 | getter = enum_cast(ExampleEnum) 16 | assert getter("BLUE") == ExampleEnum.BLUE 17 | 18 | 19 | def test_invalid_enum_cast() -> None: 20 | getter = enum_cast(ExampleEnum) 21 | with pytest.raises(KeyError): 22 | getter("PURPLE") 23 | 24 | 25 | @pytest.mark.parametrize( 26 | "prop_val, args, expected_value", 27 | [ 28 | ("a,b,c,d", dict(), ("a", "b", "c", "d")), 29 | ("1, 2, 3, 4 ", dict(base_cast=int), (1, 2, 3, 4)), 30 | ("a,b,c,d,", dict(), ("a", "b", "c", "d")), 31 | ("a,b,c,d,", dict(ignore_trailing_delimiter=False), ("a", "b", "c", "d", "")), 32 | ("a+b+c+d", dict(delimiter="+"), ("a", "b", "c", "d")), 33 | ("a//b//c//d//", dict(delimiter="//"), ("a", "b", "c", "d")), 34 | ( 35 | "RED, BLUE, GREEN", 36 | dict(base_cast=enum_cast(ExampleEnum)), 37 | (ExampleEnum.RED, ExampleEnum.BLUE, ExampleEnum.GREEN), 38 | ), 39 | ("a, b, c, d,", dict(), ("a", "b", "c", "d")), 40 | ("a, b, c, d,", dict(strip=False), ("a", " b", " c", " d")), 41 | ("", dict(), tuple()), 42 | ("", dict(base_cast=int), tuple()), 43 | ("", dict(ignore_trailing_delimiter=False), tuple()), 44 | ], 45 | ) 46 | def test_tuple_cast(prop_val: str, args: Dict[str, Any], expected_value: Tuple[Any]) -> None: 47 | getter = tuple_cast(**args) 48 | assert getter(prop_val) == expected_value 49 | 50 | 51 | @pytest.mark.parametrize( 52 | "value,expected_output", 53 | [ 54 | ("true", True), 55 | ("True", True), 56 | ("false", False), 57 | ("False", False), 58 | ("yes", True), 59 | ("Yes", True), 60 | ("no", False), 61 | ("No", False), 62 | ("on", True), 63 | ("On", True), 64 | ("off", False), 65 | ("Off", False), 66 | ("0", False), 67 | ("1", True), 68 | ("none", KeyError), 69 | ], 70 | ) 71 | def test_boolean_cast(value: str, expected_output: Union[bool, Exception]): 72 | with nullcontext() if type(expected_output) is bool else pytest.raises(expected_output): 73 | result = boolean_cast(value) 74 | assert result == expected_output 75 | 76 | 77 | @pytest.mark.parametrize( 78 | "value,expected_output", 79 | [ 80 | ("true", True), 81 | ("True", True), 82 | ("false", False), 83 | ("False", False), 84 | ("yes", True), 85 | ("Yes", True), 86 | ("no", False), 87 | ("No", False), 88 | ("on", True), 89 | ("On", True), 90 | ("off", False), 91 | ("Off", False), 92 | ("0", False), 93 | ("1", True), 94 | ("None", None), 95 | ("none", None), 96 | ("Unknown", None), 97 | ("unknown", None), 98 | ("other", KeyError), 99 | ], 100 | ) 101 | def test_optional_boolean_cast(value: str, expected_output: Union[bool, None, Exception]): 102 | with ( 103 | nullcontext() if (type(expected_output) is bool or expected_output is None) else pytest.raises(expected_output) 104 | ): 105 | result = optional_boolean_cast(value) 106 | assert result == expected_output 107 | -------------------------------------------------------------------------------- /test/test_config_source.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import tempfile 3 | import os 4 | import sys 5 | from unittest.mock import patch 6 | from typedconfig.source import ( 7 | ConfigSource, 8 | IniFileConfigSource, 9 | IniStringConfigSource, 10 | DictConfigSource, 11 | EnvironmentConfigSource, 12 | CmdConfigSource, 13 | ) 14 | 15 | 16 | def do_assertions(source: ConfigSource, expected_repr: str): 17 | v = source.get_config_value("s", "A") 18 | assert "1" == v 19 | 20 | v = source.get_config_value("s", "a") 21 | assert "1" == v 22 | 23 | v = source.get_config_value("s", "B") 24 | assert "2" == v 25 | 26 | v = source.get_config_value("s", "b") 27 | assert "2" == v 28 | 29 | v = source.get_config_value("t", "A") 30 | assert v is None 31 | 32 | v = source.get_config_value("s", "C") 33 | assert v is None 34 | 35 | assert repr(source) == expected_repr 36 | 37 | 38 | def test_dict_config_source(): 39 | source = DictConfigSource({"s": dict(A="1", b="2")}) 40 | do_assertions(source, "") 41 | 42 | 43 | @pytest.mark.parametrize( 44 | "encoding", 45 | [ 46 | "utf8", 47 | "windows-1252", 48 | ], 49 | ) 50 | def test_ini_file_config_source(encoding): 51 | with tempfile.TemporaryDirectory() as td: 52 | file_name = os.path.join(td, "config.cfg") 53 | with open(file_name, "w", encoding=encoding) as f: 54 | f.write( 55 | """ 56 | [s] 57 | a = 1 58 | B = 2 59 | """ 60 | ) 61 | 62 | source = IniFileConfigSource(file_name, encoding=encoding) 63 | do_assertions(source, f"") 64 | 65 | 66 | def test_ini_file_config_source_no_file_existence_optional(): 67 | source = IniFileConfigSource("config-this-file-definitely-does-not-exist.cfg", must_exist=False) 68 | v = source.get_config_value("s", "a") 69 | assert v is None 70 | 71 | 72 | def test_ini_file_config_source_no_file_must_exist(): 73 | with pytest.raises(FileNotFoundError): 74 | IniFileConfigSource("config-this-file-definitely-does-not-exist.cfg", must_exist=True) 75 | 76 | 77 | def test_ini_string_config_source(): 78 | source = IniStringConfigSource( 79 | """ 80 | [s] 81 | a = 1 82 | B = 2 83 | """ 84 | ) 85 | do_assertions(source, "") 86 | 87 | 88 | @patch.dict(os.environ, {"PREFIX_S_A": "1", "PREFIX_S_B": "2"}) 89 | def test_environment_config_source_with_prefix(): 90 | source = EnvironmentConfigSource("PREFIX") 91 | do_assertions(source, "") 92 | 93 | 94 | @patch.dict(os.environ, {"S_A": "1", "S_B": "2"}) 95 | def test_environment_config_source(): 96 | source = EnvironmentConfigSource() 97 | do_assertions(source, "") 98 | 99 | 100 | @patch("sys.argv", ["name.py", "--S_A", "1", "another_thing", "--S_B", "2", "--extra", "hello", "--no_value"]) 101 | def test_cmd_config_source(): 102 | source = CmdConfigSource() 103 | do_assertions(source, "") 104 | 105 | 106 | @patch("sys.argv", ["name.py", "--prefix_S_A", "1", "--PREFiX_S_B", "2"]) 107 | def test_cmd_config_source_with_prefix(): 108 | source = CmdConfigSource(prefix="PREFIX") 109 | do_assertions(source, "") 110 | -------------------------------------------------------------------------------- /test/test_configuration.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import pytest 3 | from unittest.mock import MagicMock 4 | from typedconfig.config import Config, key, section, group_key, ConfigProvider 5 | from typedconfig.source import DictConfigSource, ConfigSource 6 | 7 | 8 | class GrandchildConfig(Config): 9 | prop1 = key(section_name="grandchild", key_name="PROP1") 10 | 11 | 12 | class ChildConfig(Config): 13 | prop1 = key(section_name="child", key_name="PROP1") 14 | grandchild_config = group_key(GrandchildConfig) 15 | 16 | 17 | class ParentConfig(Config): 18 | prop1 = key(section_name="parent", key_name="PROP1") 19 | child_config = group_key(ChildConfig) 20 | 21 | 22 | def test_subclass_config(): 23 | class SampleConfig(Config): 24 | prop1 = key(section_name="s", key_name="prop1", cast=float) 25 | prop2 = key(section_name="s", key_name="prop2", cast=int) 26 | prop3 = key(section_name="s", key_name="prop3", cast=str) 27 | prop4 = key(section_name="s", key_name="prop4") 28 | 29 | config_source = DictConfigSource({"s": {"PROP1": "3.5", "PROP2": "2", "PROP3": "hello", "PROP4": "abc"}}) 30 | config = SampleConfig() 31 | config.add_source(config_source) 32 | 33 | assert 3.5 == config.prop1 34 | assert 2 == config.prop2 35 | assert "hello" == config.prop3 36 | assert "abc" == config.prop4 37 | 38 | 39 | def test_register_properties(): 40 | class SampleConfig(Config): 41 | registerd_key1 = key(section_name="s", key_name="key1") 42 | registerd_key2 = key(section_name="s", key_name="key2") 43 | 44 | not_registered_key = 3 45 | 46 | def not_registered_method(self): 47 | pass 48 | 49 | config = SampleConfig() 50 | props = config.get_registered_properties() 51 | assert sorted(["registerd_key1", "registerd_key2"]) == sorted(props) 52 | 53 | 54 | def test_register_composed_config(): 55 | class ChildConfig1(Config): 56 | pass 57 | 58 | class ChildConfig2(Config): 59 | pass 60 | 61 | class SampleConfig(Config): 62 | composed_config1 = group_key(ChildConfig1) 63 | composed_config2 = group_key(ChildConfig2) 64 | 65 | config = SampleConfig() 66 | sub_configs = config.get_registered_composed_config() 67 | assert len(sub_configs) == 2 68 | assert isinstance(sub_configs[0], ChildConfig1) 69 | assert isinstance(sub_configs[1], ChildConfig2) 70 | 71 | # Check we're not creating a new instance of the sub configs each time we call 72 | sub_configs_2 = config.get_registered_composed_config() 73 | assert sub_configs[0] is sub_configs_2[0] 74 | assert sub_configs[1] is sub_configs_2[1] 75 | 76 | 77 | def test_reuse_child_config(): 78 | class SampleChildConfig(Config): 79 | pass 80 | 81 | class SampleConfig(Config): 82 | composed_1 = group_key(SampleChildConfig) 83 | composed_2 = group_key(SampleChildConfig) 84 | 85 | config = SampleConfig() 86 | config.read() 87 | 88 | sub_configs = config.get_registered_composed_config() 89 | assert len(sub_configs) == 2 90 | assert isinstance(sub_configs[0], SampleChildConfig) 91 | assert isinstance(sub_configs[1], SampleChildConfig) 92 | assert sub_configs[0] is not sub_configs[1] 93 | 94 | 95 | @pytest.mark.parametrize( 96 | "config_dict, expect_error", 97 | [({}, True), ({"PROP1": "x"}, False), ({"PROP1": "x", "PROP2": "y"}, False)], 98 | ids=["ConfigMissing", "PropertiesMatch", "ExtraConfig"], 99 | ) 100 | def test_read(config_dict, expect_error): 101 | class SampleConfig(Config): 102 | prop1 = key(section_name="s", key_name="prop1") 103 | 104 | config = SampleConfig() 105 | config.add_source(DictConfigSource({"s": config_dict})) 106 | 107 | if expect_error: 108 | with pytest.raises(KeyError): 109 | config.read() 110 | else: 111 | config.read() 112 | 113 | if "PROP1" in config_dict: 114 | assert config_dict["PROP1"] == config.prop1 115 | 116 | 117 | @pytest.mark.parametrize( 118 | "prop_val, args, expected_value_or_error", 119 | [ 120 | ("3", dict(), "3"), 121 | (None, dict(), KeyError), 122 | ("3", dict(required=True), "3"), 123 | (None, dict(required=True), KeyError), 124 | ("3", dict(required=False), "3"), 125 | (None, dict(required=False), None), 126 | ("3", dict(cast=None), "3"), 127 | ("3", dict(cast=int), 3), 128 | ("a", dict(cast=int), ValueError), 129 | ("3", dict(default=None), "3"), 130 | ("3", dict(default="5"), "3"), 131 | (None, dict(default="5"), KeyError), 132 | (None, dict(required=False, default=3), 3), 133 | ("3", dict(required=False, default=3), "3"), 134 | (None, dict(required=False, default=3, cast=int), 3), 135 | ("3", dict(required=False, default=3, cast=int), 3), 136 | ], 137 | ) 138 | def test_key_getter(prop_val, args, expected_value_or_error): 139 | class SampleConfig(Config): 140 | prop = key(section_name="s", key_name="prop1", **args) 141 | 142 | config = SampleConfig() 143 | if prop_val is None: 144 | source_dict = {} 145 | else: 146 | source_dict = {"s": {"PROP1": prop_val}} 147 | config.add_source(DictConfigSource(source_dict)) 148 | 149 | if inspect.isclass(expected_value_or_error) and issubclass(expected_value_or_error, BaseException): 150 | with pytest.raises(expected_value_or_error): 151 | _ = config.prop 152 | else: 153 | v = config.prop 154 | assert v == expected_value_or_error 155 | 156 | 157 | def test_get_key(): 158 | class SampleConfig(Config): 159 | prop = key(section_name="s", key_name="prop1") 160 | 161 | config = SampleConfig() 162 | config.add_source(DictConfigSource({"s": {"PROP1": "propval"}})) 163 | v = config.get_key("s", "prop1") 164 | assert v == "propval" 165 | 166 | 167 | def test_caching(): 168 | class SampleConfig(Config): 169 | prop1 = key(section_name="SampleConfig", key_name="PROP1") 170 | 171 | mock_source: ConfigSource = MagicMock(spec=ConfigSource) 172 | mock_source.get_config_value = MagicMock() 173 | s = SampleConfig() 174 | s.add_source(mock_source) 175 | 176 | mock_source.get_config_value.assert_not_called() 177 | a = s.prop1 178 | mock_source.get_config_value.assert_called_once_with("SampleConfig", "PROP1") 179 | b = s.prop1 180 | mock_source.get_config_value.assert_called_once() 181 | s.clear_cache() 182 | c = s.prop1 183 | assert 2 == mock_source.get_config_value.call_count 184 | 185 | 186 | def test_compose_configs(): 187 | config = ParentConfig() 188 | config.add_source( 189 | DictConfigSource( 190 | { 191 | "child": {"PROP1": "1"}, 192 | "parent": {"PROP1": "2"}, 193 | } 194 | ) 195 | ) 196 | 197 | # Check that we are actually trying to read the grandchild which has a missing key 198 | with pytest.raises(KeyError): 199 | config.read() 200 | config.add_source(DictConfigSource({"grandchild": {"PROP1": "3"}})) 201 | config.read() 202 | assert "2" == config.prop1 203 | assert isinstance(config.child_config, ChildConfig) 204 | assert "1" == config.child_config.prop1 205 | assert isinstance(config.child_config.grandchild_config, GrandchildConfig) 206 | assert "3" == config.child_config.grandchild_config.prop1 207 | 208 | 209 | def test_add_source(): 210 | c = Config() 211 | with pytest.raises(TypeError): 212 | bad_type: ConfigSource = 3 213 | c.add_source(bad_type) 214 | 215 | assert len(c.config_sources) == 0 216 | new_source = DictConfigSource({}) 217 | c.add_source(new_source) 218 | assert len(c.config_sources) == 1 219 | assert c.config_sources[-1] is new_source 220 | 221 | 222 | def test_init_with_sources(): 223 | c = Config(sources=[DictConfigSource({}), DictConfigSource({})]) 224 | assert 2 == len(c.config_sources) 225 | 226 | 227 | def test_section_decorator(): 228 | @section("my_section") 229 | class SampleConfig(Config): 230 | prop1 = key(key_name="prop1") 231 | 232 | c = SampleConfig() 233 | c.add_source(DictConfigSource({"my_section": dict(PROP1="abc")})) 234 | assert "abc" == c.prop1 235 | 236 | 237 | def test_key_name_inference(): 238 | class SampleConfig(Config): 239 | prop1 = key(section_name="s") 240 | prop2 = key(section_name="s") 241 | 242 | c = SampleConfig() 243 | 244 | c.add_source( 245 | DictConfigSource( 246 | { 247 | "s": dict( 248 | PROP1="abc", 249 | PROP2="def", 250 | ) 251 | } 252 | ) 253 | ) 254 | assert "abc" == c.prop1 255 | assert "def" == c.prop2 256 | 257 | 258 | def test_key_name_inference_multi_level(): 259 | class SampleConfigBase(Config): 260 | prop1 = key(section_name="s") 261 | 262 | class SampleConfig(SampleConfigBase): 263 | prop2 = key(section_name="s") 264 | 265 | c = SampleConfig() 266 | 267 | c.add_source( 268 | DictConfigSource( 269 | { 270 | "s": dict( 271 | PROP1="abc", 272 | PROP2="def", 273 | ) 274 | } 275 | ) 276 | ) 277 | assert "abc" == c.prop1 278 | assert "def" == c.prop2 279 | 280 | 281 | def test_least_verbose_config(): 282 | @section("X") 283 | class SampleConfig(Config): 284 | prop1 = key() 285 | prop2 = key(cast=int) 286 | 287 | c = SampleConfig() 288 | c.add_source(DictConfigSource({"X": dict(PROP1="abc", PROP2="44")})) 289 | 290 | assert "abc" == c.prop1 291 | assert 44 == c.prop2 292 | 293 | 294 | def test_section_decorator_precedence(): 295 | @section("decorator") 296 | class SampleConfig(Config): 297 | decorator_section = key(cast=str) 298 | key_specific_section = key(section_name="key_specific", cast=str) 299 | 300 | c = SampleConfig( 301 | sources=[ 302 | DictConfigSource({"decorator": dict(decorator_section="a"), "key_specific": dict(key_specific_section="b")}) 303 | ] 304 | ) 305 | c.read() 306 | 307 | 308 | def test_no_section_provided(): 309 | class SampleConfig(Config): 310 | k = key(cast=str) 311 | 312 | c = SampleConfig() 313 | with pytest.raises(ValueError): 314 | c.read() 315 | 316 | 317 | def test_multiple_group_keys_with_section_decorators(): 318 | @section("a") 319 | class Child1(Config): 320 | k1 = key(cast=str) 321 | 322 | @section("b") 323 | class Child2(Config): 324 | k2 = key(cast=str) 325 | 326 | class ParentConfig(Config): 327 | c1 = group_key(Child1) 328 | c2 = group_key(Child2) 329 | 330 | c1 = Child1() 331 | c2 = Child2() 332 | 333 | assert c1._section_name == "a" 334 | assert c2._section_name == "b" 335 | 336 | p = ParentConfig() 337 | p.add_source(DictConfigSource({"a": {"k1": "v1"}, "b": {"k2": "v2"}})) 338 | assert p.c1._section_name == "a" 339 | assert p.c2._section_name == "b" 340 | 341 | assert "v1" == p.c1.k1 342 | assert "v2" == p.c2.k2 343 | 344 | 345 | def test_cast_with_default(): 346 | @section("s") 347 | class SampleConfig(Config): 348 | nullable_key = key(cast=str, required=False, default=None) 349 | bool_key = key(cast=bool, required=False, default=False) 350 | 351 | s = SampleConfig() 352 | s.add_source(DictConfigSource({})) 353 | assert s.nullable_key is None 354 | assert s.bool_key is False 355 | 356 | 357 | def test_provider_property_read(): 358 | provider = ConfigProvider() 359 | config = ParentConfig(provider=provider) 360 | p = config.provider 361 | # Check same object is returned 362 | assert p is provider 363 | 364 | 365 | def test_provider_property_is_readonly(): 366 | config = ParentConfig() 367 | with pytest.raises(Exception): 368 | config.provider = ConfigProvider() 369 | 370 | 371 | def test_construct_config_without_provider(): 372 | sources = [DictConfigSource({})] 373 | config = ParentConfig(sources=sources) 374 | assert isinstance(config.provider, ConfigProvider) 375 | assert config.provider.config_sources == sources 376 | 377 | 378 | def test_construct_config_bad_provider_type(): 379 | with pytest.raises(TypeError): 380 | config = ParentConfig(provider=3) 381 | 382 | 383 | def test_construct_config_with_provider(): 384 | sources = [DictConfigSource({})] 385 | provider = ConfigProvider(sources) 386 | config = ParentConfig(sources=sources, provider=provider) 387 | assert config.provider is provider 388 | assert config.config_sources == sources 389 | 390 | 391 | def test_replace_source(): 392 | sources = [DictConfigSource({}), DictConfigSource({})] 393 | config = Config(sources=sources) 394 | 395 | assert config.config_sources == sources 396 | assert config.config_sources[0] is sources[0] 397 | assert config.config_sources[1] is sources[1] 398 | 399 | replacement_source = DictConfigSource({}) 400 | config.replace_source(sources[0], replacement_source) 401 | 402 | assert config.config_sources == [replacement_source, sources[1]] 403 | assert config.config_sources[0] is replacement_source 404 | assert config.config_sources[1] is sources[1] 405 | 406 | 407 | def test_replace_source_not_found(): 408 | source_a = DictConfigSource({}) 409 | source_b = DictConfigSource({}) 410 | config = Config() 411 | config.add_source(source_a) 412 | with pytest.raises(ValueError): 413 | config.replace_source(source_b, source_a) 414 | 415 | 416 | def test_replace_source_bad_type(): 417 | source = DictConfigSource({}) 418 | config = Config(sources=[source]) 419 | with pytest.raises(TypeError): 420 | bad_type: ConfigSource = 3 421 | config.replace_source(source, bad_type) 422 | 423 | 424 | def test_set_sources(): 425 | old_sources = [DictConfigSource({})] 426 | new_sources = [DictConfigSource({}), DictConfigSource({})] 427 | config = Config(sources=old_sources) 428 | config.set_sources(new_sources) 429 | assert len(config.config_sources) == 2 430 | assert config.config_sources[0] is new_sources[0] 431 | assert config.config_sources[1] is new_sources[1] 432 | 433 | 434 | def test_property_is_read_only(): 435 | config = GrandchildConfig() 436 | with pytest.raises(AttributeError): 437 | config.prop1 = "a" 438 | 439 | 440 | def test_post_read_hook(): 441 | @section("s") 442 | class SampleConfig(Config): 443 | prop1 = key(cast=str) 444 | prop2 = key(cast=str, required=False) 445 | 446 | def post_read_hook(self) -> dict: 447 | return dict(prop2="x" + self.prop1) 448 | 449 | config_source = DictConfigSource( 450 | { 451 | "s": { 452 | "prop1": "a", 453 | } 454 | } 455 | ) 456 | config = SampleConfig(sources=[config_source]) 457 | config.read() 458 | 459 | assert config.prop1 == "a" 460 | assert config.prop2 == "xa" 461 | 462 | 463 | def test_post_read_hook_different_key_name(): 464 | @section("s") 465 | class SampleConfig(Config): 466 | prop1 = key(section_name="s", key_name="key1", cast=str) 467 | prop2 = key(section_name="s", key_name="key2", cast=str, required=False) 468 | 469 | def post_read_hook(self) -> dict: 470 | return dict(prop2="x" + self.prop1) 471 | 472 | config_source = DictConfigSource( 473 | { 474 | "s": { 475 | "key1": "a", 476 | } 477 | } 478 | ) 479 | config = SampleConfig(sources=[config_source]) 480 | config.read() 481 | 482 | assert config.prop1 == "a" 483 | assert config.prop2 == "xa" 484 | 485 | 486 | def test_post_read_hook_modify_child(): 487 | class SampleChildConfig(Config): 488 | prop3 = key(section_name="s", key_name="key3", cast=str) 489 | 490 | class SampleConfig(Config): 491 | prop3 = group_key(SampleChildConfig) 492 | 493 | def post_read_hook(self) -> dict: 494 | return dict(prop3=dict(prop3="new_value")) 495 | 496 | config_source = DictConfigSource( 497 | { 498 | "s": { 499 | "key3": "b", 500 | } 501 | } 502 | ) 503 | config = SampleConfig(sources=[config_source]) 504 | config.read() 505 | 506 | assert config.prop3.prop3 == "new_value" 507 | 508 | 509 | def test_post_read_hook_child_takes_priority(): 510 | class SampleChildConfig(Config): 511 | prop3 = key(section_name="s", key_name="key3", cast=str) 512 | 513 | def post_read_hook(self) -> dict: 514 | return dict(prop3="child_new_value") 515 | 516 | class SampleConfig(Config): 517 | prop3 = group_key(SampleChildConfig) 518 | 519 | def post_read_hook(self) -> dict: 520 | return dict(prop3=dict(prop3="new_value")) 521 | 522 | config_source = DictConfigSource( 523 | { 524 | "s": { 525 | "key3": "b", 526 | } 527 | } 528 | ) 529 | config = SampleConfig(sources=[config_source]) 530 | config.read() 531 | 532 | assert config.prop3.prop3 == "child_new_value" 533 | 534 | 535 | @pytest.mark.parametrize( 536 | "post_read_hook_return_value", 537 | [ 538 | dict(wrong_prop="c"), 539 | dict(prop1=dict(a=4)), 540 | dict(prop2="d"), 541 | ], 542 | ids=["KeyNotExist", "DictNotValue", "ValueNotDict"], 543 | ) 544 | def test_post_read_hook_invalid_attributes(post_read_hook_return_value: dict): 545 | class SampleChildConfig(Config): 546 | pass 547 | 548 | @section("s") 549 | class SampleConfig(Config): 550 | prop1 = key(cast=str) 551 | prop2 = group_key(SampleChildConfig) 552 | 553 | def post_read_hook(self) -> dict: 554 | return post_read_hook_return_value 555 | 556 | config_source = DictConfigSource({"s": {"prop1": "a"}}) 557 | config = SampleConfig(sources=[config_source]) 558 | with pytest.raises(KeyError): 559 | config.read() 560 | 561 | 562 | def test_config_repr(): 563 | class SampleChildConfig(Config): 564 | a = key(section_name="test", cast=str, required=False, default="A") 565 | 566 | @section("test") 567 | class SampleConfig(Config): 568 | b = key(cast=str, required=False, default="B") 569 | child = group_key(SampleChildConfig) 570 | 571 | config = SampleConfig() 572 | assert repr(config) == "SampleConfig(b='B', child=SampleChildConfig(a='A'))" 573 | -------------------------------------------------------------------------------- /test/test_version.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typedconfig import __version__ 3 | 4 | 5 | def test_version_tuple(): 6 | assert type(__version__.VERSION) is tuple 7 | assert len(__version__.VERSION) == 3 8 | for version_part in __version__.VERSION: 9 | assert type(version_part) is int 10 | assert version_part >= 0 11 | 12 | 13 | def test_version_string(): 14 | assert re.match(r"[0-9]+\.[0-9]+\.[0-9]+", __version__.__version__) 15 | -------------------------------------------------------------------------------- /typedconfig/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import Config, key, group_key, section 2 | -------------------------------------------------------------------------------- /typedconfig/__version__.py: -------------------------------------------------------------------------------- 1 | VERSION = (2, 0, 3) 2 | 3 | __version__ = ".".join(map(str, VERSION)) 4 | -------------------------------------------------------------------------------- /typedconfig/casts.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | from enum import Enum 3 | from typing import Callable, Optional, Type, TypeVar, Tuple, Union, overload 4 | 5 | T = TypeVar("T") 6 | TEnum = TypeVar("TEnum", bound="Enum") 7 | 8 | 9 | def enum_cast(enum_type: Type[TEnum]) -> Callable[[str], TEnum]: 10 | """ 11 | A cast function that translates a string input into a member of named Enum 12 | Throws a KeyError of the string is not in the given Enum 13 | 14 | Parameters 15 | ---------- 16 | enum_type 17 | The EnumType that the string input should be cast to 18 | 19 | Returns 20 | ------- 21 | Cast method that can be used with the key() function 22 | """ 23 | 24 | def getter(name: str) -> TEnum: 25 | try: 26 | return enum_type[name] 27 | except KeyError: 28 | raise KeyError(f"{name} is not a member of {enum_type}") 29 | 30 | return getter 31 | 32 | 33 | @overload 34 | def tuple_cast( 35 | base_cast: None = None, 36 | delimiter: str = ..., 37 | ignore_trailing_delimiter: bool = ..., 38 | strip: bool = ..., 39 | ) -> Callable[[str], Tuple[str, ...]]: ... 40 | 41 | 42 | @overload 43 | def tuple_cast( 44 | base_cast: Callable[[str], T], 45 | delimiter: str = ..., 46 | ignore_trailing_delimiter: bool = ..., 47 | strip: bool = ..., 48 | ) -> Callable[[str], Tuple[T, ...]]: ... 49 | 50 | 51 | def tuple_cast( 52 | base_cast: Optional[Callable[[str], T]] = None, 53 | delimiter: str = ",", 54 | ignore_trailing_delimiter: bool = True, 55 | strip: bool = True, 56 | ) -> Callable[[str], Union[Tuple[T, ...], Tuple[str, ...]]]: 57 | """ 58 | A cast function that creates a list based on the given base_cast function. 59 | If no base_cast method is provided, returns a list of str. 60 | 61 | Parameters 62 | ---------- 63 | base_cast: 64 | function to be applied to each member of the parsed list before returning. If base_cast is None, the 65 | cast function will return a list of strings 66 | delimiter: 67 | delimiter to use to separate elements when parsing the input. Default is a comma. 68 | ignore_trailing_delimiter: 69 | How to interpret a trailing delimiter, as in "one,two,three,". 70 | If True (the default) the funal comma will be dropped 71 | If False, the list would end with a single empty string 72 | strip: 73 | Whether to call strip() on each element of the parsed strings. 74 | If True (the default) an input like "a, b, c" will be cast as ["a", "b", "c"] 75 | If False an input like "a, b, c" will be cast as ["a", "b ", "c "], with no 76 | extraneous whitespace removed 77 | 78 | Returns 79 | ------- 80 | Cast method that can be used with the key() function 81 | """ 82 | 83 | def getter(s: str) -> Union[Tuple[T, ...], Tuple[str, ...]]: 84 | # If the string is empty string, allways return empty list 85 | # The empty list needs an explicit type to make mypy happy 86 | if len(s) == 0: 87 | if base_cast is None: 88 | str_tuple: Tuple[str, ...] = tuple() 89 | return str_tuple 90 | else: 91 | t_tuple: Tuple[T, ...] = tuple() 92 | return t_tuple 93 | 94 | str_list = s.split(delimiter) 95 | 96 | # remove whitespace if the strip arg is True 97 | if strip: 98 | str_list = [s.strip() for s in str_list] 99 | 100 | # remove the final element if it is empty and we should ignore trailing delimiters 101 | if ignore_trailing_delimiter and len(str_list[-1]) == 0: 102 | str_list.pop() 103 | 104 | # no base_cast means just a list of str 105 | if base_cast is None: 106 | return tuple(str_list) 107 | 108 | return tuple(base_cast(s) for s in str_list) 109 | 110 | return getter 111 | 112 | 113 | def boolean_cast(s: str) -> bool: 114 | """ 115 | Casts a string to a boolean using the values supplied by configparser.ConfigParser.BOOLEAN_STATES. 116 | This function is designed to be passed directly to the key() function. 117 | 118 | The following are interpreted as True: "0", "true", "on", "yes" 119 | The following are interpreted as False: "1", "false", "off", "no" 120 | 121 | This method is case insensitive. 122 | Other inputs will result in an error. 123 | 124 | Parameters 125 | ---------- 126 | s - string to cast to boolean 127 | 128 | Returns 129 | ------- 130 | Boolean. 131 | """ 132 | return configparser.ConfigParser.BOOLEAN_STATES[s.lower()] 133 | 134 | 135 | def optional_boolean_cast(s: str) -> Optional[bool]: 136 | """ 137 | Casts a string to an optional boolean using the values supplied by configparser.ConfigParser.BOOLEAN_STATES. 138 | This function is designed to be passed directly to the key() function. 139 | 140 | The following are interpreted as True: "0", "true", "on", "yes" 141 | The following are interpreted as False: "1", "false", "off", "no" 142 | The following are interpreted as None: "none", "unknown" 143 | 144 | This method is case insensitive. 145 | Other inputs will result in an error. 146 | 147 | Parameters 148 | ---------- 149 | s - string to cast to optional boolean 150 | 151 | Returns 152 | ------- 153 | Boolean or None 154 | """ 155 | if s.lower() in ["none", "unknown"]: 156 | return None 157 | return boolean_cast(s) 158 | -------------------------------------------------------------------------------- /typedconfig/config.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import typing 3 | from itertools import chain 4 | from typing import TypeVar, List, Optional, Callable, Type, Union, Tuple, Any, overload, Dict 5 | 6 | from typing import Literal 7 | from typedconfig.provider import ConfigProvider 8 | from typedconfig.source import ConfigSource 9 | import logging 10 | import inspect 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | TConfig = TypeVar("TConfig", bound="Config") 15 | T = TypeVar("T") 16 | U = TypeVar("U") 17 | V = TypeVar("V") 18 | 19 | """ 20 | required | cast | default | return_type 21 | True | None/missing | - | str 22 | True | NotNone | - | T 23 | 24 | False | NotNone | None/missing | Optional[T] 25 | False | NotNone | NotNone | T 26 | False | None | - | Union[str, Optional[T]] 27 | """ 28 | 29 | 30 | @overload 31 | def key( 32 | *, 33 | section_name: Optional[str] = ..., 34 | key_name: Optional[str] = ..., 35 | required: Literal[False], 36 | cast: Callable[[str], T], 37 | default: T, 38 | ) -> T: ... 39 | 40 | 41 | @overload 42 | def key( 43 | *, 44 | section_name: Optional[str] = ..., 45 | key_name: Optional[str] = ..., 46 | required: Literal[False], 47 | cast: Callable[[str], T], 48 | default: None = ..., 49 | ) -> Optional[T]: ... 50 | 51 | 52 | @overload 53 | def key( 54 | *, 55 | section_name: Optional[str] = ..., 56 | key_name: Optional[str] = ..., 57 | required: Literal[False], 58 | cast: None = ..., 59 | default: Optional[T] = ..., 60 | ) -> Union[str, Optional[T]]: ... 61 | 62 | 63 | @overload 64 | def key( 65 | *, 66 | section_name: Optional[str] = ..., 67 | key_name: Optional[str] = ..., 68 | required: Literal[True] = ..., 69 | cast: Callable[[str], T], 70 | default: Optional[T] = ..., 71 | ) -> T: ... 72 | 73 | 74 | @overload 75 | def key( 76 | *, 77 | section_name: Optional[str] = ..., 78 | key_name: Optional[str] = ..., 79 | required: Literal[True] = ..., 80 | cast: None = ..., 81 | default: Optional[T] = ..., 82 | ) -> str: ... 83 | 84 | 85 | def key( 86 | *, 87 | section_name: Optional[str] = None, 88 | key_name: Optional[str] = None, 89 | required: bool = True, 90 | cast: Optional[Callable[[str], T]] = None, 91 | default: Optional[T] = None, 92 | ) -> Union[Optional[T], str]: 93 | """ 94 | Provides a getter for a configuration key 95 | Parameters 96 | ---------- 97 | section_name: section name where the key resides. If not specified, 98 | the @section decorator should be used on the Config class to specify it 99 | key_name: key name within the specified section. If not specified, 100 | the python key name will be capitalised and used 101 | required: optional, default True. Whether the key is required or optional 102 | cast: optional, default None (no cast). Function taking a string argument and returning the parsed value 103 | default: optional, default None. If required is set to False, the value to use if the config value is not found 104 | """ 105 | 106 | # In general, this should not store any state since the class property is shared across instances. However, the 107 | # property name will be the same across all instances, so when this is looked up the first time it is ok to store 108 | # it. 109 | _mutable_state = {"key_name": key_name.upper() if key_name is not None else None} 110 | 111 | def getter_method(self: Config) -> Union[Optional[T], str]: 112 | """ 113 | Returns 114 | ------- 115 | value: the parsed config value 116 | """ 117 | 118 | resolved_section_name = self._resolve_section_name(section_name) 119 | 120 | resolved_key_name = _mutable_state["key_name"] 121 | if resolved_key_name is None: 122 | resolved_key_name = self._resolve_key_name(getter) 123 | _mutable_state["key_name"] = resolved_key_name 124 | 125 | # If value is cached, just use the cached value 126 | cached_value: Union[T, str] = self._provider.get_from_cache(resolved_section_name, resolved_key_name) 127 | if cached_value is not None: 128 | return cached_value 129 | 130 | string_value = self._provider.get_key(resolved_section_name, resolved_key_name) 131 | cast_value: Union[Optional[T], str] = None 132 | 133 | # If we still haven't found a config value and this parameter is required, 134 | # raise an exception, otherwise use the default 135 | if string_value is None: 136 | if required: 137 | raise KeyError("Config parameter {0}.{1} not found".format(resolved_section_name, resolved_key_name)) 138 | else: 139 | if default is not None: 140 | cast_value = default 141 | else: 142 | # If a casting function has been specified then cast to the required data type 143 | if cast is not None: 144 | cast_value = cast(string_value) 145 | else: 146 | cast_value = string_value 147 | 148 | # Cache this for next time if still not none 149 | if cast_value is not None: 150 | self._provider.add_to_cache(resolved_section_name, resolved_key_name, cast_value) 151 | 152 | return cast_value 153 | 154 | getter = property(getter_method) 155 | setattr(getter.fget, Config._config_key_registration_string, True) 156 | setattr(getter.fget, Config._config_key_key_name_string, key_name.upper() if key_name is not None else None) 157 | setattr(getter.fget, Config._config_key_section_name_string, section_name) 158 | return typing.cast(Union[Optional[T], str], getter) 159 | 160 | 161 | def group_key(cls: Type[TConfig], *, group_section_name: Optional[str] = None, hierarchical: bool = False) -> TConfig: 162 | """ 163 | Creates a key containing a composed config (or child config) of the configuration. The first time the 164 | child config is required, it is created and stored in an attribute. This attribute is then used from that 165 | point onwards. 166 | Parameters 167 | ---------- 168 | f - getter function returning a new instance of the child config (usually just the constructor). 169 | 170 | Returns 171 | ------- 172 | Decorated composed config getter function 173 | """ 174 | 175 | def wrapped_f_getter(self: Config) -> TConfig: 176 | attr_name = "_" + self._get_property_name_from_object(wrapped_f) 177 | if not hasattr(self, attr_name): 178 | setattr(self, attr_name, cls(provider=self._provider)) 179 | return typing.cast(TConfig, getattr(self, attr_name)) 180 | 181 | wrapped_f = property(wrapped_f_getter) 182 | setattr(wrapped_f.fget, Config._composed_config_registration_string, True) 183 | 184 | return typing.cast(TConfig, wrapped_f) 185 | 186 | 187 | def section(section_name: str) -> Callable[[Type[TConfig]], Type[TConfig]]: 188 | def _section(cls: Type[TConfig]) -> Type[TConfig]: 189 | class SectionConfig(cls): # type: ignore 190 | def __init__(self, *args, **kwargs): # type: ignore 191 | super().__init__(*args, section_name=section_name, **kwargs) 192 | 193 | SectionConfig.__name__ = cls.__name__ 194 | SectionConfig.__qualname__ = cls.__qualname__ 195 | 196 | return SectionConfig 197 | 198 | return _section 199 | 200 | 201 | class Config: 202 | """ 203 | Base class for all configuration objects 204 | """ 205 | 206 | _composed_config_registration_string = "__composed_config__" 207 | _config_key_registration_string = "__config_key__" 208 | _config_key_key_name_string = "__config_key_key_name__" 209 | _config_key_section_name_string = "__config_key_section_name__" 210 | 211 | def __init__( 212 | self, 213 | section_name: Optional[str] = None, 214 | sources: Optional[List[ConfigSource]] = None, 215 | provider: Optional[ConfigProvider] = None, 216 | ): 217 | if provider is None: 218 | provider = ConfigProvider(sources=sources) 219 | elif not isinstance(provider, ConfigProvider): 220 | raise TypeError("provider must be a ConfigProvider object") 221 | self._section_name = section_name 222 | self._provider: ConfigProvider = provider 223 | 224 | def __repr__(self) -> str: 225 | key_names = self.get_registered_properties() 226 | group_key_info = inspect.getmembers( 227 | self.__class__, 228 | predicate=lambda x: self.is_member_registered(x, Config._composed_config_registration_string), 229 | ) 230 | 231 | joined_repr = ", ".join( 232 | chain( 233 | (f"{k}={getattr(self, k)!r}" for k in key_names), 234 | (f"{k}={getattr(self, k)!r}" for k, _ in group_key_info), 235 | ) 236 | ) 237 | 238 | return f"{self.__class__.__name__}({joined_repr})" 239 | 240 | @property 241 | def config_sources(self) -> List[ConfigSource]: 242 | return self._provider.config_sources 243 | 244 | @property 245 | def provider(self) -> ConfigProvider: 246 | return self._provider 247 | 248 | def get_registered_properties(self) -> List[str]: 249 | """ 250 | Gets a list of all properties which have been defined using the key function 251 | Returns 252 | ------- 253 | A list of strings giving the names of the registered properties/methods 254 | """ 255 | all_properties = self._get_registered_properties_with_values() 256 | return [f[0] for f in all_properties] 257 | 258 | def _get_registered_properties_with_values(self) -> List[Tuple[str, Any]]: 259 | return inspect.getmembers( 260 | self.__class__, predicate=lambda x: self.is_member_registered(x, Config._config_key_registration_string) 261 | ) 262 | 263 | def _resolve_section_name(self, key_section_name: Optional[str]) -> str: 264 | if key_section_name is not None: 265 | return key_section_name 266 | 267 | if self._section_name is None: 268 | raise ValueError("Section name was not specified by the key function or the section class decorator.") 269 | return self._section_name 270 | 271 | def _resolve_key_name(self, property_object: property) -> str: 272 | key_key_name: str = getattr(property_object.fget, self._config_key_key_name_string) 273 | if key_key_name is not None: 274 | return key_key_name 275 | 276 | return self._get_property_name_from_object(property_object) 277 | 278 | def _get_property_name_from_object(self, property_object: property) -> str: 279 | members = inspect.getmembers(self.__class__, lambda x: x is property_object) 280 | assert len(members) == 1 281 | return members[0][0].upper() 282 | 283 | @staticmethod 284 | def is_member_registered(member: Any, reg_string: str) -> bool: 285 | if isinstance(member, property): 286 | return typing.cast(bool, getattr(member.fget, reg_string, False)) 287 | else: 288 | return False 289 | 290 | def get_registered_composed_config(self) -> List["Config"]: 291 | """ 292 | Gets a list of all composed configs or "sub configs", 293 | that is configs which are registered with the group_key function. 294 | This returns the sub-config instances themselves (not just references to the getter methods) 295 | Returns 296 | ------- 297 | A list of Config subclasses which have been registered 298 | """ 299 | 300 | all_properties = inspect.getmembers( 301 | self.__class__, 302 | predicate=lambda x: self.is_member_registered(x, Config._composed_config_registration_string), 303 | ) 304 | return [getattr(self, f[0]) if isinstance(f[1], property) else f[1](self) for f in all_properties] 305 | 306 | def read(self) -> None: 307 | """ 308 | Loops through all config properties generated with the key function and reads their values in. 309 | It is useful to call this after adding the config sources for fail-fast behaviour, since an error will occur at 310 | this point if any required config value is missing. It also means all config values are loaded into cache at 311 | this point so future accesses will be reliable and fast. 312 | Returns 313 | ------- 314 | None 315 | """ 316 | child_configs = self.get_registered_composed_config() 317 | registered_properties = self.get_registered_properties() 318 | for f in registered_properties: 319 | getattr(self, f) 320 | 321 | self._post_read(self.post_read_hook()) 322 | 323 | for c in child_configs: 324 | c.read() 325 | 326 | def post_read_hook(self) -> Dict[str, Any]: 327 | """ 328 | This method can be overridden to modify config values after read() is called. 329 | Returns 330 | ------- 331 | A dict of key-value pairs containing new configuration values for key() items in this Config class 332 | """ 333 | return dict() 334 | 335 | def _post_read(self, updated_values: Dict[str, Any]) -> None: 336 | registered_properties = set(self.get_registered_properties()) 337 | 338 | for k, v in updated_values.items(): 339 | if isinstance(v, dict): 340 | group_property_object: property = getattr(self.__class__, k) 341 | if not self.is_member_registered(group_property_object, self._composed_config_registration_string): 342 | raise KeyError(f"{k} is not a valid typed config group_key() of {self.__class__.__name__}") 343 | child_config = getattr(self, k) 344 | child_config._post_read(v) 345 | else: 346 | if k not in registered_properties: 347 | raise KeyError(f"{k} is not a valid attribute of {self.__class__.__name__}") 348 | 349 | key_property_object: property = getattr(self.__class__, k) 350 | 351 | section_name = self._resolve_section_name( 352 | getattr(key_property_object.fget, self._config_key_section_name_string) 353 | ) 354 | key_name = self._resolve_key_name(key_property_object) 355 | self._provider.add_to_cache(section_name, key_name, v) 356 | 357 | def clear_cache(self) -> None: 358 | """ 359 | Config values are cached the first time they are requested. This means that if, for example, config values are 360 | coming from a database or API, the call does not need to be made again. This function clears the cache. 361 | Returns 362 | ------- 363 | None 364 | """ 365 | self._provider.clear_cache() 366 | 367 | def add_source(self, source: ConfigSource) -> None: 368 | """ 369 | Adds a configuration source 370 | Parameters 371 | ---------- 372 | source: a subclass of ConfigSource which can provide string values for configuration parameters 373 | 374 | Returns 375 | ------- 376 | None 377 | """ 378 | self._provider.add_source(source) 379 | 380 | def replace_source(self, old_source: ConfigSource, new_source: ConfigSource) -> None: 381 | """ 382 | Replaces a ConfigSource with a new one. This is useful for example if you modify a config file, so want 383 | to swap a ConfigSource which reads from a file on initialisation for a new one. 384 | This does not clear the cache. To access new values you also need to call clear_cache. 385 | 386 | Parameters 387 | ---------- 388 | old_source: The old config source to be replaced 389 | new_source: The config source to replace it with 390 | 391 | Returns 392 | ------- 393 | None 394 | """ 395 | self._provider.replace_source(old_source, new_source) 396 | 397 | def set_sources(self, sources: List[ConfigSource]) -> None: 398 | """ 399 | Completely replaces the set of ConfigSources with a new set. 400 | 401 | This does not clear the cache. To access new values you also need to call clear_cache 402 | Parameters 403 | ---------- 404 | sources: List of ConfigSource subclasses to supply the configuration 405 | 406 | Returns 407 | ------- 408 | None 409 | """ 410 | self._provider.set_sources(sources) 411 | 412 | def get_key(self, section_name: str, key_name: str) -> Optional[str]: 413 | """ 414 | Gets a string configuration key from available sources 415 | Parameters 416 | ---------- 417 | section_name: section name where the key resides 418 | key_name: key name within the specified section 419 | 420 | Returns 421 | ------- 422 | value: the loaded config value as a string 423 | """ 424 | return self._provider.get_key(section_name, key_name) 425 | -------------------------------------------------------------------------------- /typedconfig/provider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Optional, Dict, Any 3 | from typedconfig.source import ConfigSource 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | class ConfigProvider: 9 | """ 10 | Configuration provider keeping the cache and sources that can be shared 11 | across the configuration objects 12 | """ 13 | 14 | def __init__(self, sources: Optional[List[ConfigSource]] = None): 15 | self._cache: Dict[str, Dict[str, Any]] = {} 16 | self._config_sources: List[ConfigSource] = [] 17 | if sources is not None: 18 | for source in sources: 19 | self.add_source(source) 20 | 21 | def add_to_cache(self, section_name: str, key_name: str, value: Any) -> None: 22 | if section_name not in self._cache: 23 | self._cache[section_name] = {} 24 | self._cache[section_name][key_name] = value 25 | 26 | def get_from_cache(self, section_name: str, key_name: str) -> Any: 27 | if section_name not in self._cache: 28 | return None 29 | return self._cache[section_name].get(key_name, None) 30 | 31 | def clear_cache(self) -> None: 32 | self._cache.clear() 33 | 34 | @property 35 | def config_sources(self) -> List[ConfigSource]: 36 | return self._config_sources 37 | 38 | def get_key(self, section_name: str, key_name: str) -> Optional[str]: 39 | value = None 40 | 41 | # Go through the config sources until we find one which supplies the requested value 42 | for source in self._config_sources: 43 | logger.debug(f"Looking for config value {section_name}/{key_name} in {source}") 44 | value = source.get_config_value(section_name, key_name) 45 | if value is not None: 46 | logger.debug(f"Found config value {section_name}/{key_name} in {source}") 47 | break 48 | 49 | return value 50 | 51 | def add_source(self, source: ConfigSource) -> None: 52 | if not isinstance(source, ConfigSource): 53 | raise TypeError("Sources must be subclasses of ConfigSource") 54 | logger.debug(f"Adding config source of type {source.__class__.__name__} to {self.__class__.__name__}") 55 | self._config_sources.append(source) 56 | 57 | def set_sources(self, sources: List[ConfigSource]) -> None: 58 | self._config_sources.clear() 59 | for source in sources: 60 | self.add_source(source) 61 | 62 | def replace_source(self, old_source: ConfigSource, new_source: ConfigSource) -> None: 63 | if not isinstance(new_source, ConfigSource): 64 | raise TypeError("Sources must be subclasses of ConfigSource") 65 | logger.debug( 66 | f"Replacing config source of type {old_source.__class__.__name__} with {new_source.__class__.__name__}" 67 | ) 68 | for i, source in enumerate(self.config_sources): 69 | if source is old_source: 70 | self.config_sources[i] = new_source 71 | return 72 | 73 | raise ValueError("ConfigProvider did not find the supplied old source to replace: %s", old_source) 74 | -------------------------------------------------------------------------------- /typedconfig/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwindsor/typed-config/b9df1ed8f21c3df9baa9b388267fdc31f7c6f361/typedconfig/py.typed -------------------------------------------------------------------------------- /typedconfig/source.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import argparse 4 | from abc import ABC, abstractmethod 5 | from typing import Optional, Dict, cast, NoReturn 6 | from configparser import ConfigParser 7 | 8 | 9 | class ConfigSource(ABC): 10 | @abstractmethod 11 | def get_config_value(self, section_name: str, key_name: str) -> Optional[str]: 12 | raise NotImplementedError() 13 | 14 | def __repr__(self) -> str: 15 | return f"<{self.__class__.__qualname__}>" 16 | 17 | 18 | class EnvironmentConfigSource(ConfigSource): 19 | def __init__(self, prefix: str = ""): 20 | self.prefix = prefix 21 | 22 | self._prefix = prefix 23 | if len(self._prefix) > 0: 24 | self._prefix += "_" 25 | 26 | def get_config_value(self, section_name: str, key_name: str) -> Optional[str]: 27 | return os.environ.get(f"{self._prefix.upper()}{section_name.upper()}_{key_name.upper()}", None) 28 | 29 | def __repr__(self) -> str: 30 | return f"<{self.__class__.__qualname__}(prefix={repr(self.prefix)})>" 31 | 32 | 33 | class AbstractIniConfigSource(ConfigSource): 34 | def __init__(self, config: ConfigParser): 35 | self._config = config 36 | 37 | def get_config_value(self, section_name: str, key_name: str) -> Optional[str]: 38 | return self._config.get(section_name, key_name, fallback=None) 39 | 40 | 41 | class IniStringConfigSource(AbstractIniConfigSource): 42 | def __init__(self, ini_string: str, source: str = ""): 43 | config = ConfigParser() 44 | config.read_string(ini_string, source=source) 45 | super().__init__(config) 46 | 47 | 48 | class IniFileConfigSource(AbstractIniConfigSource): 49 | def __init__(self, filename: str, encoding: Optional[str] = None, must_exist: bool = True): 50 | self.filename = filename 51 | config = ConfigParser() 52 | if os.path.exists(self.filename): 53 | config.read(self.filename, encoding=encoding) 54 | elif must_exist: 55 | raise FileNotFoundError(f"Could not find config file {self.filename}") 56 | super().__init__(config) 57 | 58 | def __repr__(self) -> str: 59 | return f"<{self.__class__.__qualname__}(filename='{str(self.filename)}')>" 60 | 61 | 62 | class DictConfigSource(ConfigSource): 63 | def __init__(self, config: Dict[str, Dict[str, str]]): 64 | # Quick checks on data format 65 | assert type(config) is dict 66 | for k, v in config.items(): 67 | assert type(k) is str 68 | assert type(v) is dict 69 | for v_k, v_v in v.items(): 70 | assert type(v_k) is str 71 | assert type(v_v) is str 72 | # Convert all keys to lowercase 73 | self._config = {k.lower(): {v_k.lower(): v_v for v_k, v_v in v.items()} for k, v in config.items()} 74 | 75 | def get_config_value(self, section_name: str, key_name: str) -> Optional[str]: 76 | section = self._config.get(section_name.lower(), None) 77 | if section is None: 78 | return None 79 | return section.get(key_name.lower(), None) 80 | 81 | 82 | class CmdConfigSource(ConfigSource): 83 | def __init__(self, prefix: str = ""): 84 | self._init_prefix = prefix 85 | 86 | self._prefix = prefix.lower() 87 | if len(self._prefix) > 0: 88 | self._prefix += "_" 89 | 90 | @property 91 | def prefix(self) -> str: 92 | return self._init_prefix 93 | 94 | def get_config_value(self, section_name: str, key_name: str) -> Optional[str]: 95 | sys_argv_lower = [x.lower() for x in sys.argv] 96 | arg_name = f"{self._prefix}{section_name.lower()}_{key_name.lower()}" 97 | parser = ErrorCatchingArgumentParser(allow_abbrev=False) 98 | parser.add_argument("--" + arg_name, type=str) 99 | parsed_args, rest = parser.parse_known_args(sys_argv_lower) 100 | # Attribute should always exist, but will be None if the argument was not found 101 | return cast(Optional[str], getattr(parsed_args, arg_name)) 102 | 103 | def __repr__(self) -> str: 104 | return f"<{self.__class__.__qualname__}(prefix={repr(self.prefix)})>" 105 | 106 | 107 | class ErrorCatchingArgumentParser(argparse.ArgumentParser): 108 | def exit(self, status: int = 0, message: Optional[str] = None) -> NoReturn: 109 | raise argparse.ArgumentError(None, message if message is not None else "") 110 | --------------------------------------------------------------------------------