first commit of bison work

This commit is contained in:
Erick Daniszewski 2018-03-08 00:54:13 -05:00
parent 00ac18b4b4
commit 2899d78659
No known key found for this signature in database
GPG Key ID: DEA43A5D586F3E0E
21 changed files with 2770 additions and 2 deletions

4
.gitignore vendored
View File

@ -45,6 +45,7 @@ nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache
# Translations
*.mo
@ -99,3 +100,6 @@ ENV/
# mypy
.mypy_cache/
# IDE
.idea

49
Makefile Normal file
View File

@ -0,0 +1,49 @@
#
# bison - python application configuration
#
HAS_PIPENV := $(shell which pipenv)
PKG_VERSION := $(shell cat bison/__version__.py | grep __version__ | awk '{print $$3}')
requires-pipenv:
ifndef HAS_PIPENV
@echo "pipenv required, but not found: run 'pip install pipenv --upgrade'"
exit 1
endif
.PHONY: init
init: requires-pipenv ## Initialize the project for development
pipenv install --dev --skip-lock
.PHONY: test
test: ## Run the bison unit tests
pipenv run py.test
.PHONY: ci
ci: test lint ## Run the ci pipeline (test, lint)
.PHONY: lint
lint: requires-pipenv ## Run static analysis / linting on bison
pipenv run flake8 --ignore=E501,E712 bison
pipenv run isort bison tests -rc -c --diff
.PHONY: coverage
coverage: requires-pipenv ## Show the coverage report for unit tests
pipenv run py.test --cov-report term --cov-report html --cov=bison tests
.PHONY: publish
publish: ## Publish bison to Pypi
pip install 'twine>=1.5.0'
python setup.py sdist bdist_wheel
twine upload dist/*
rm -rf build dist .egg bison.egg-info
.PHONY: version
version: ## Print the current version of the package
@echo $(PKG_VERSION)
.PHONY: help
help: ## Print Make usage information
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | sort
.DEFAULT_GOAL := help

18
Pipfile Normal file
View File

@ -0,0 +1,18 @@
[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true
name = "pypi"
[dev-packages]
"pytest" = ">=2.8.0"
"pytest-cov" = "*"
"isort" = "*"
"flake8" = "*"
"tox" = "*"
[packages]
"e1839a8" = {path = ".", editable = true}

192
Pipfile.lock generated Normal file
View File

@ -0,0 +1,192 @@
{
"_meta": {
"hash": {
"sha256": "fe0c46193a8684df9ffe0aef713c89d89219b59d1a89556e077620a673917c0b"
},
"pipfile-spec": 6,
"requires": {},
"sources": [
{
"name": "pypi",
"url": "https://pypi.python.org/simple",
"verify_ssl": true
}
]
},
"default": {
"e1839a8": {
"editable": true,
"path": "."
},
"pyyaml": {
"hashes": [
"sha256:0c507b7f74b3d2dd4d1322ec8a94794927305ab4cebbe89cc47fe5e81541e6e8",
"sha256:16b20e970597e051997d90dc2cddc713a2876c47e3d92d59ee198700c5427736",
"sha256:3262c96a1ca437e7e4763e2843746588a965426550f3797a79fca9c6199c431f",
"sha256:326420cbb492172dec84b0f65c80942de6cedb5233c413dd824483989c000608",
"sha256:4474f8ea030b5127225b8894d626bb66c01cda098d47a2b0d3429b6700af9fd8",
"sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab",
"sha256:5ac82e411044fb129bae5cfbeb3ba626acb2af31a8d17d175004b70862a741a7",
"sha256:5f84523c076ad14ff5e6c037fe1c89a7f73a3e04cf0377cb4d017014976433f3",
"sha256:827dc04b8fa7d07c44de11fabbc888e627fa8293b695e0f99cb544fdfa1bf0d1",
"sha256:b4c423ab23291d3945ac61346feeb9a0dc4184999ede5e7c43e1ffb975130ae6",
"sha256:bc6bced57f826ca7cb5125a10b23fd0f2fff3b7c4701d64c439a300ce665fff8",
"sha256:c01b880ec30b5a6e6aa67b09a2fe3fb30473008c85cd6a67359a1b15ed6d83a4",
"sha256:ca233c64c6e40eaa6c66ef97058cdc80e8d0157a443655baa1b2966e812807ca",
"sha256:e863072cdf4c72eebf179342c94e6989c67185842d9997960b3e69290b2fa269"
],
"version": "==3.12"
}
},
"develop": {
"attrs": {
"hashes": [
"sha256:1c7960ccfd6a005cd9f7ba884e6316b5e430a3f1a6c37c5f87d8b43f83b54ec9",
"sha256:a17a9573a6f475c99b551c0e0a812707ddda1ec9653bed04c13841404ed6f450"
],
"version": "==17.4.0"
},
"colorama": {
"hashes": [
"sha256:463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda",
"sha256:48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1"
],
"markers": "sys_platform == 'win32'",
"version": "==0.3.9"
},
"coverage": {
"hashes": [
"sha256:03481e81d558d30d230bc12999e3edffe392d244349a90f4ef9b88425fac74ba",
"sha256:0b136648de27201056c1869a6c0d4e23f464750fd9a9ba9750b8336a244429ed",
"sha256:104ab3934abaf5be871a583541e8829d6c19ce7bde2923b2751e0d3ca44db60a",
"sha256:15b111b6a0f46ee1a485414a52a7ad1d703bdf984e9ed3c288a4414d3871dcbd",
"sha256:198626739a79b09fa0a2f06e083ffd12eb55449b5f8bfdbeed1df4910b2ca640",
"sha256:1c383d2ef13ade2acc636556fd544dba6e14fa30755f26812f54300e401f98f2",
"sha256:28b2191e7283f4f3568962e373b47ef7f0392993bb6660d079c62bd50fe9d162",
"sha256:2eb564bbf7816a9d68dd3369a510be3327f1c618d2357fa6b1216994c2e3d508",
"sha256:337ded681dd2ef9ca04ef5d93cfc87e52e09db2594c296b4a0a3662cb1b41249",
"sha256:3a2184c6d797a125dca8367878d3b9a178b6fdd05fdc2d35d758c3006a1cd694",
"sha256:3c79a6f7b95751cdebcd9037e4d06f8d5a9b60e4ed0cd231342aa8ad7124882a",
"sha256:3d72c20bd105022d29b14a7d628462ebdc61de2f303322c0212a054352f3b287",
"sha256:3eb42bf89a6be7deb64116dd1cc4b08171734d721e7a7e57ad64cc4ef29ed2f1",
"sha256:4635a184d0bbe537aa185a34193898eee409332a8ccb27eea36f262566585000",
"sha256:56e448f051a201c5ebbaa86a5efd0ca90d327204d8b059ab25ad0f35fbfd79f1",
"sha256:5a13ea7911ff5e1796b6d5e4fbbf6952381a611209b736d48e675c2756f3f74e",
"sha256:69bf008a06b76619d3c3f3b1983f5145c75a305a0fea513aca094cae5c40a8f5",
"sha256:6bc583dc18d5979dc0f6cec26a8603129de0304d5ae1f17e57a12834e7235062",
"sha256:701cd6093d63e6b8ad7009d8a92425428bc4d6e7ab8d75efbb665c806c1d79ba",
"sha256:7608a3dd5d73cb06c531b8925e0ef8d3de31fed2544a7de6c63960a1e73ea4bc",
"sha256:76ecd006d1d8f739430ec50cc872889af1f9c1b6b8f48e29941814b09b0fd3cc",
"sha256:7aa36d2b844a3e4a4b356708d79fd2c260281a7390d678a10b91ca595ddc9e99",
"sha256:7d3f553904b0c5c016d1dad058a7554c7ac4c91a789fca496e7d8347ad040653",
"sha256:7e1fe19bd6dce69d9fd159d8e4a80a8f52101380d5d3a4d374b6d3eae0e5de9c",
"sha256:8c3cb8c35ec4d9506979b4cf90ee9918bc2e49f84189d9bf5c36c0c1119c6558",
"sha256:9d6dd10d49e01571bf6e147d3b505141ffc093a06756c60b053a859cb2128b1f",
"sha256:9e112fcbe0148a6fa4f0a02e8d58e94470fc6cb82a5481618fea901699bf34c4",
"sha256:ac4fef68da01116a5c117eba4dd46f2e06847a497de5ed1d64bb99a5fda1ef91",
"sha256:b8815995e050764c8610dbc82641807d196927c3dbed207f0a079833ffcf588d",
"sha256:be6cfcd8053d13f5f5eeb284aa8a814220c3da1b0078fa859011c7fffd86dab9",
"sha256:c1bb572fab8208c400adaf06a8133ac0712179a334c09224fb11393e920abcdd",
"sha256:de4418dadaa1c01d497e539210cb6baa015965526ff5afc078c57ca69160108d",
"sha256:e05cb4d9aad6233d67e0541caa7e511fa4047ed7750ec2510d466e806e0255d6",
"sha256:e4d96c07229f58cb686120f168276e434660e4358cc9cf3b0464210b04913e77",
"sha256:f3f501f345f24383c0000395b26b726e46758b71393267aeae0bd36f8b3ade80",
"sha256:f8a923a85cb099422ad5a2e345fe877bbc89a8a8b23235824a93488150e45f6e"
],
"version": "==4.5.1"
},
"flake8": {
"hashes": [
"sha256:7253265f7abd8b313e3892944044a365e3f4ac3fcdcfb4298f55ee9ddf188ba0",
"sha256:c7841163e2b576d435799169b78703ad6ac1bbb0f199994fc05f700b2a90ea37"
],
"version": "==3.5.0"
},
"funcsigs": {
"hashes": [
"sha256:330cc27ccbf7f1e992e69fef78261dc7c6569012cf397db8d3de0234e6c937ca",
"sha256:a7bb0f2cf3a3fd1ab2732cb49eba4252c2af4240442415b4abce3b87022a8f50"
],
"markers": "python_version < '3.0'",
"version": "==1.0.2"
},
"isort": {
"hashes": [
"sha256:1153601da39a25b14ddc54955dbbacbb6b2d19135386699e2ad58517953b34af",
"sha256:b9c40e9750f3d77e6e4d441d8b0266cf555e7cdabdcff33c4fd06366ca761ef8",
"sha256:ec9ef8f4a9bc6f71eec99e1806bfa2de401650d996c59330782b89a5555c1497"
],
"version": "==4.3.4"
},
"mccabe": {
"hashes": [
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
"sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
],
"version": "==0.6.1"
},
"pluggy": {
"hashes": [
"sha256:7f8ae7f5bdf75671a718d2daf0a64b7885f74510bcd98b1a0bb420eb9a9d0cff"
],
"version": "==0.6.0"
},
"py": {
"hashes": [
"sha256:8cca5c229d225f8c1e3085be4fcf306090b00850fefad892f9d96c7b6e2f310f",
"sha256:ca18943e28235417756316bfada6cd96b23ce60dd532642690dcfdaba988a76d"
],
"version": "==1.5.2"
},
"pycodestyle": {
"hashes": [
"sha256:682256a5b318149ca0d2a9185d365d8864a768a28db66a84a2ea946bcc426766",
"sha256:6c4245ade1edfad79c3446fadfc96b0de2759662dc29d07d80a6f27ad1ca6ba9"
],
"version": "==2.3.1"
},
"pyflakes": {
"hashes": [
"sha256:08bd6a50edf8cffa9fa09a463063c425ecaaf10d1eb0335a7e8b1401aef89e6f",
"sha256:8d616a382f243dbf19b54743f280b80198be0bca3a5396f1d2e1fca6223e8805"
],
"version": "==1.6.0"
},
"pytest": {
"hashes": [
"sha256:062027955bccbc04d2fcd5d79690947e018ba31abe4c90b2c6721abec734261b",
"sha256:117bad36c1a787e1a8a659df35de53ba05f9f3398fb9e4ac17e80ad5903eb8c5"
],
"version": "==3.4.2"
},
"pytest-cov": {
"hashes": [
"sha256:03aa752cf11db41d281ea1d807d954c4eda35cfa1b21d6971966cc041bbf6e2d",
"sha256:890fe5565400902b0c78b5357004aab1c814115894f4f21370e2433256a3eeec"
],
"version": "==2.5.1"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
],
"version": "==1.11.0"
},
"tox": {
"hashes": [
"sha256:752f5ec561c6c08c5ecb167d3b20f4f4ffc158c0ab78855701a75f5cef05f4b8",
"sha256:8af30fd835a11f3ff8e95176ccba5a4e60779df4d96a9dfefa1a1704af263225"
],
"version": "==2.9.1"
},
"virtualenv": {
"hashes": [
"sha256:02f8102c2436bb03b3ee6dede1919d1dac8a427541652e5ec95171ec8adbc93a",
"sha256:39d88b533b422825d644087a21e78c45cf5af0ef7a99a1fc9fbb7b481e5c85b0"
],
"markers": "python_version != '3.2'",
"version": "==15.1.0"
}
}
}

214
README.md
View File

@ -1,2 +1,212 @@
# bison
Python application configuration
<p align="center">
<a href="https://pypi.python.org/pypi/bison"><img src="https://img.shields.io/pypi/v/bison.svg"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/edaniszewski/bison.svg"></a>
<h1 align="center">bison</h1>
</p>
<p align="center">Python application configuration</p>
## What is Bison?
Bison is a configuration solution for Python applications that aims to be simple
and intuitive. It supports:
* reading from YAML config files
* reading from environment variables
* setting explicit values
* setting defaults
* configuration validation
* configuration access/manipulation with dot notation
Instead of implementing custom configuration reading and parsing, you can use
Bison to handle it for you.
Bison was inspired by [Viper][viper] and the lack of good
application configuration solutions for Python (at least, in my opinion). Documentation
for Bison can be found on [ReadtheDocs][docs]
Bison uses the following precedence order. Each item in the list takes precedence
over the item below it.
- override (e.g. calling `Bison.set()`)
- environment
- config file
- defaults
## Installation
Bison can be installed with `pip`
```
pip install bison
```
or with `pipenv`
```
pipenv install bison
```
## Using Bison
### Creating a configuration Scheme
A configuration scheme is not required by Bison, but having one allows you to set default
values for configuration fields as well as do configuration validation. It is pretty easy
to create a new Scheme:
```python
scheme = bison.Scheme()
```
A Scheme is really just a container for configuration options, so without any options, a
Scheme is somewhat useless.
#### Configuration Options
There are currently three types of configuration options:
- `bison.Option`
- `bison.DictOption`
- `bison.ListOption`
Their intended functionality should be mostly obvious from their names. An `Option` represents
a singular value in a configuration. A `DictOption` represents a dictionary or mapping of values
in a configuration. A `ListOption` represents a list of values in a configuration.
See the [documentation][docs] for more on how options can be configured.
Any number of options can be added to a Scheme, but as a simple example we can define a Scheme
which expects a key "log", and a key "count".
```python
scheme = bison.Scheme(
bison.Option('log'),
bison.Option('count'),
)
```
#### Configuration Validation
Validation operates based on the constraints set on the options. Above, there are no
constraints (other than the need for those keys to exist), so any value for "log" and
"count" will pass validation.
An option can be constrained in different ways by using its keyword arguments. For example,
to ensure the value for "count" is an integer,
```python
bison.Option('count', field_type=int)
```
Or, to restrict the values to a set of choices
```python
bison.Option('log', choices=['debug', 'info', 'warn', 'error'])
```
The [documentation][docs] goes into more detail about other validation settings.
#### Setting Defaults
If a default value is not set on an option, it is considered required. In these cases,
if the key specified by that value is not present in the parse configuration, it will
cause a validation failure.
If a default value is set, then the absence of that field in the configuration will not
cause a validation failure.
```python
bison.Option('log', default='info')
```
### Configuring Bison
Once you have a Scheme to use (if you'd like to), it will need to be passed to a Bison
object to manage the config building.
```python
scheme = bison.Scheme()
config = bison.Bison(scheme)
```
There are a few options that can be set on the Bison object to change how it
searches for and builds the unified configuration.
For reading configuration files
```python
config.config_name = 'config' # name of the config file (no extension)
config.add_config_paths( # paths to look in for the config file
'.',
'/tmp/app'
)
config.config_format = bison.YAML # the config format to use
```
For reading environment variables
```python
config.env_prefix = "MY_APP" # the prefix to use for environment variables
config.auto_env = True # automatically bind all options to env variables based on their key
```
### Building the unified config
Once the scheme has been set (if using) and Bison has been configured, the only thing
left to do is to read in all the config sources and parse them into a unified config.
This is done simply with
```python
config.parse()
```
### Example
Below is a complete example for parsing a hypothetical application configuration which
is described by the following YAML config.
```yaml
log: debug
port: 5000
settings:
requests:
timeout: 3
backends:
- host: 10.1.2.3
port: 5001
- host: 10.1.2.4
port: 5013
- host: 10.1.2.5
port: 5044
```
```python
import bison
# the scheme for the configuration. this allows us to set defaults
# and validate configuration data
config_scheme = bison.Scheme(
bison.Option('log', default='info', choices=['debug', 'info', 'warn', 'error']),
bison.Option('port', field_type=int),
bison.DictOption('settings', scheme=bison.Scheme(
bison.DictOption('requests', scheme=bison.Scheme(
bison.Option('timeout', field_type=int)
))
)),
bison.ListOption('backends', member_scheme=bison.Scheme(
bison.Option('host', field_type=str),
bison.Option('port', field_type=int)
))
)
# create a new Bison instance to store and manage configuration data
config = bison.Bison(scheme=config_scheme)
# set the config file name to 'app' (default is 'config') and set the
# search paths to '.' and '/tmp/app/config'
config.config_name = 'app'
config.add_config_paths('.', '/tmp/app/config')
# set the environment variable prefix and enable auto-env
config.env_prefix = 'MY_APP'
config.auto_env = True
# finally, parse the config sources to build the unified configuration
config.parse()
```
See the [example](example) directory for this example along with demonstrations
of how to access configuration data.
## Future Work
There is more that can be done to improve Bison and expand its functionality. If
you wish to contribute, open a pull request. If you have questions or feature requests,
open an issue. Below are some high level ideas for future improvements:
* Support additional configuration formats (JSON, TOML, ...)
* Versioned configurations
[docs]: http://readthedocs
[viper]: https://github.com/spf13/viper

16
bison/__init__.py Normal file
View File

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
"""
bison
~~~~~
A Python application configuration library.
"""
# flake8: noqa
from .__version__ import __author__, __author_email__, __copyright__
from .__version__ import __description__, __license__, __url__
from .__version__ import __title__, __version__
from .bison import Bison
from .scheme import DictOption, ListOption, Option, Scheme
from .utils import DotDict

10
bison/__version__.py Normal file
View File

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
__title__ = 'bison'
__description__ = 'Python application configuration'
__url__ = 'https://github.com/edaniszewski/bison'
__version__ = '0.0.1'
__author__ = 'Erick Daniszewski'
__author_email__ = 'edaniszewski@gmail.com'
__license__ = 'MIT'
__copyright__ = 'Copyright 2018 Erick Daniszewski'

261
bison/bison.py Normal file
View File

@ -0,0 +1,261 @@
# -*- coding: utf-8 -*-
"""
bison.bison
~~~~~~~~~~~
This module implements the `bison` API.
"""
import logging
import os
import yaml
from bison.errors import BisonError
from bison.scheme import Option
from bison.utils import DotDict, cast
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
# enumerate the supported configuration formats
YAML, = range(1)
class Bison(object):
"""The configuration management object.
The `Bison` object is used to search for, read, parse, and validate
application configurations based on a set of defaults, configuration
files, environment variables, and overrides. `Bison` is not intended
to incorporate command line arguments.
By default, `Bison` is configured to look for a file named 'config'
with the YAML format (file extension .yml or .yaml).
Args:
scheme (bison.Scheme): The scheme to use for validation when parsing
a configuration file. If no scheme is provided, no validation will
occur. (default: None)
enable_logging (bool): Enable `bison` logging. If this is set to True,
`bison` will log out at the logging.INFO level; otherwise, logging
for `bison` is disabled. (default: False)
"""
# map the configuration format to its supported file extensions
_fmt_to_ext = {
YAML: ('.yml', '.yaml')
}
# map the configuration format to the function is uses to load
# the configuration data from file.
_fmt_to_parser = {
YAML: yaml.safe_load
}
def __init__(self, scheme=None, enable_logging=False):
logger.disabled = not enable_logging
self.scheme = scheme
self.config_name = 'config' # the name of the config file
self.config_format = YAML # format of the config file
self.config_paths = [] # the path(s) to search for the config file
self.config_file = None # the config file to read
self.env_prefix = None # the environment variable prefix
self.auto_env = False # automatically bind options to env variables
# the component configurations
self._default = DotDict()
self._config = DotDict()
self._environment = DotDict()
self._override = DotDict()
# the unified configuration.
self._full_config = None
@property
def config(self):
"""Get the complete configuration where the default, config,
environment, and override values are merged together.
Returns:
(DotDict): A dictionary of configuration values that
allows lookups using dot notation.
"""
if self._full_config is None:
self._full_config = DotDict()
self._full_config.update(self._default)
self._full_config.update(self._config)
self._full_config.update(self._environment)
self._full_config.update(self._override)
return self._full_config
def get(self, key, default=None):
"""Get the value for the configuration `key`.
Args:
key (str): The key into the configuration dictionary to get.
default: The value to return if the given `key` is not found
in the `Bison` config. This defaults to `None`.
Returns:
The value for the given key, if it exists; `None` otherwise.
"""
return self.config.get(key, default)
def set(self, key, value):
"""Set a value in the `Bison` configuration.
Args:
key (str): The configuration key to set a new value for.
value: The value to set.
"""
# the configuration changes, so we invalidate the cached config
self._full_config = None
self._override[key] = value
def add_config_paths(self, *paths):
"""Add paths to search for the configuration file.
Args:
*paths (str): The paths to search in for the configuration file.
"""
self.config_paths.extend(paths)
def validate(self):
"""Validate the `Bison` configuration against the `Scheme`, if one
is set.
Raises:
errors.SchemeValidationError: The `Bison` configuration fails
schema validation.
"""
if self.scheme:
self.scheme.validate(self.config)
def parse(self):
"""Parse the configuration sources into `Bison`."""
self._parse_default()
self._parse_config()
self._parse_env()
def _find_config(self):
"""Searches through the configured `config_paths` for the `config_name`
file.
If there are no `config_paths` defined, this will raise an error, so the
caller should take care to check the value of `config_paths` first.
Returns:
str: The fully qualified path to the configuration that was found.
Raises:
Exception: No paths are defined in `config_paths` or no file with
the `config_name` was found in any of the specified `config_paths`.
"""
for search_path in self.config_paths:
for ext in self._fmt_to_ext.get(self.config_format):
path = os.path.abspath(os.path.join(search_path, self.config_name + ext))
if os.path.isfile(path):
self.config_file = path
return
raise BisonError('No file named {} found in search paths {}'.format(
self.config_name, self.config_paths))
def _parse_config(self):
"""Parse the configuration file, if one is configured, and add it to
the `Bison` state.
"""
if len(self.config_paths) > 0:
self._find_config()
try:
with open(self.config_file, 'r') as f:
parsed = self._fmt_to_parser[self.config_format](f)
except Exception as e:
raise BisonError(
'Failed to parse config file: {}'.format(self.config_file)
) from e
# the configuration changes, so we invalidate the cached config
self._full_config = None
self._config = parsed
def _parse_env(self):
"""Parse the environment variables for any configuration if an `env_prefix`
is set.
"""
env_cfg = DotDict()
# if the env prefix doesn't end with '_', we'll append it here
if self.env_prefix and not self.env_prefix.endswith('_'):
self.env_prefix = self.env_prefix + '_'
# if there is no scheme, we won't know what to look for so only parse
# config if there is a scheme.
if self.scheme:
for k, v in self.scheme.flatten().items():
# only Options can be bound to env variables currently.
if not isinstance(v, Option):
continue
# we explicitly do not want to bind the option to env
if v.bind_env is False:
continue
# we want to bind the option to env. in this case, bind_env is
# generated from the Option key.
elif v.bind_env is True:
env_key = k.replace('.', '_').upper()
# if an env prefix exists, use it
if self.env_prefix:
env_key = self.env_prefix.upper() + env_key
env = os.environ.get(env_key, None)
if env is not None:
env_cfg[k] = cast(v, env)
# bind_env holds the env variable to use. since it is specified
# manually, we do not prepend the env prefix.
elif isinstance(v.bind_env, str):
env_key = v.bind_env
env = os.environ.get(env_key, None)
if env is not None:
env_cfg[k] = cast(v, env)
# bind_env is None - this is its default value. in this case, the
# option hasn't been explicitly set as False, so we can do env
# lookups if auto_env is set.
elif v.bind_env is None:
if self.auto_env:
env_key = k.replace('.', '_').upper()
# if an env prefix exists, use it
if self.env_prefix:
env_key = self.env_prefix.upper() + env_key
env = os.environ.get(env_key, None)
if env is not None:
env_cfg[k] = cast(v, env)
if len(env_cfg) > 0:
# the configuration changes, so we invalidate the cached config
self._full_config = None
self._environment.update(env_cfg)
def _parse_default(self):
"""Parse the `Schema` for the `Bison` instance to create the set of
default values.
If no defaults are specified in the `Schema`, the default dictionary
will not contain anything.
"""
# the configuration changes, so we invalidate the cached config
self._full_config = None
if self.scheme:
self._default.update(self.scheme.build_defaults())

19
bison/errors.py Normal file
View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
"""
bison.errors
~~~~~~~~~~~
This module defines the errors used by `bison`.
"""
class BisonError(Exception):
"""The base class for all `bison` errors."""
class InvalidSchemeError(BisonError):
"""Error for when a `Scheme` contains incorrect members."""
class SchemeValidationError(BisonError):
"""An error for when a `bison` scheme fails validation."""

292
bison/scheme.py Normal file
View File

@ -0,0 +1,292 @@
# -*- coding: utf-8 -*-
"""
bison.scheme
~~~~~~~~~~~~
This module defines the `Scheme`, which is used by `Bison`
in order to do configuration defaults and validation. A
`Scheme` is composed of various options, which are defined
here as well.
"""
from bison import errors
class NoDefault:
"""Defines a "no default" type.
This is used in place of `None` in an options' `default` field, since
`None` is a valid default value, so checking the presence/absence of
a default value cannot be a NoneType check.
"""
# global _NoDefault instance to use as the default value for options.
_no_default = NoDefault()
class Scheme(object):
"""The `Scheme` specifies the expected options for a configuration.
It provides the template for what is expected when parsing and building
configuration state. Additionally, it allows the user to specify default
values for various fields. The `Scheme` allows for validation across all
specified options, to the extent that the constraints are specified on
those options.
"""
def __init__(self, *args):
self.args = args
self._flat = None
def build_defaults(self):
"""Build a dictionary of default values from the `Scheme`.
Returns:
dict: The default configurations as set by the `Scheme`.
Raises:
errors.InvalidSchemeError: The `Scheme` does not contain
valid options.
"""
defaults = {}
for arg in self.args:
if not isinstance(arg, _BaseOpt):
raise errors.InvalidSchemeError('')
# if there is a default set, add it to the defaults dict
if not isinstance(arg.default, NoDefault):
defaults[arg.name] = arg.default
# if we have a dict option, build the defaults for its scheme.
# if any defaults exist, use them.
if isinstance(arg, DictOption):
b = arg.scheme.build_defaults()
if b:
defaults[arg.name] = b
return defaults
def flatten(self):
"""Flatten the scheme into a dictionary where the keys are
compound 'dot' notation keys, and the values are the corresponding
options.
Returns:
dict: The flattened `Scheme`.
"""
if self._flat is None:
flat = {}
for arg in self.args:
if isinstance(arg, Option):
flat[arg.name] = arg
elif isinstance(arg, ListOption):
flat[arg.name] = arg
elif isinstance(arg, DictOption):
flat[arg.name] = DictOption
for k, v in arg.scheme.flatten().items():
flat[arg.name + '.' + k] = v
self._flat = flat
return self._flat
def validate(self, config):
"""Validate the given config against the `Schema`.
Args:
config (dict): The configuration to validate.
Raises:
errors.SchemeValidationError: The configuration fails
validation against the `Schema`.
"""
if not isinstance(config, dict):
raise errors.SchemeValidationError(
'Scheme can only validate a dictionary config, but was given '
'{} (type: {})'.format(config, type(config))
)
for arg in self.args:
# the option exists in the config
if arg.name in config:
arg.validate(config[arg.name])
# the option does not exist in the config
else:
# if the option has a default value, then it is
# considered optional and is fine to omit, otherwise
# it is considered to be required and its omission
# is a validation error.
if type(arg.default) == NoDefault:
raise errors.SchemeValidationError(
'Option "{}" is expected but not found.'.format(arg.name)
)
class _BaseOpt(object):
"""Base class for all scheme options"""
def __init__(self):
self.name = None
self.default = NoDefault
def validate(self, value):
"""Validate that the option constraints are met by the configuration.
Args:
value: The value corresponding with the option.
Raises:
errors.SchemeValidationError: The option failed validation.
"""
raise NotImplementedError
class Option(_BaseOpt):
"""Option represents a configuration option with a singular value.
For YAML, this would be a single k:v pair, e.g.
debug: True
The above could be represented by the following:
Option('debug', field_type=bool)
Args:
name (str): The name of the option - this should correspond to the key
for the option in a configuration file, e.g.
default: The default value to use. By default, this is the internal
`_NoDefault` type. If the value of this is anything other than
`_NoDefault`, this option is considered optional, so validation will
not fail if it is not present in the config. If this is left at its
default value of `_NoDefault`, then this option is considered required
and will fail validation if not present.
field_type: The type that the option value should have.
choices (list|tuple): The valid options for the field.
bind_env (bool|str|None): Bind the option to an environment variable.
"""
def __init__(self, name, default=_no_default, field_type=None, choices=None, bind_env=None):
super(Option, self).__init__()
self.name = name
self.default = default
self.type = field_type
self.choices = choices
self.bind_env = bind_env
def validate(self, value):
if (self.type is not None) and (type(value) != self.type):
raise errors.SchemeValidationError(
'{} is of type {}, but should be {}'.format(value, type(value), self.type)
)
if (self.choices is not None) and (value not in self.choices):
raise errors.SchemeValidationError(
'{} is not in the valid options: {}'.format(value, self.choices)
)
class DictOption(_BaseOpt):
"""DictOption represents a configuration option with a dictionary value.
For YAML, this would be a k:v pair where the value is a map, e.g.
some_key:
nested_key: value
The above could be represented by the following:
DictOption('some_key', scheme=Scheme(
Option('nested_key')
))
Args:
name (str): The name of the option - this should correspond to the key
for the option in a configuration file, e.g.
scheme (Scheme|None): A Scheme that defines what the dictionary config should
adhere to. This can be None if no validation is wanted on the option.
default: The default value to use. By default, this is the internal
`_NoDefault` type. If the value of this is anything other than
`_NoDefault`, this option is considered optional, so validation will
not fail if it is not present in the config. If this is left at its
default value of `_NoDefault`, then this option is considered required
and will fail validation if not present.
bind_env (bool): Bind the option to an environment variable.
"""
def __init__(self, name, scheme, default=_no_default, bind_env=False):
super(DictOption, self).__init__()
self.name = name
self.default = default
self.scheme = scheme
self.bind_env = bind_env
def validate(self, value):
if not isinstance(value, dict):
raise errors.SchemeValidationError('{} is not a dictionary'.format(value))
if isinstance(self.scheme, Scheme):
self.scheme.validate(value)
class ListOption(_BaseOpt):
"""ListOption represents a configuration option with a list value.
For YAML, this would be a k:v pair where the value is a list, e.g.
animals:
- bison
- buffalo
The above could be represented by the following:
ListOption('animals', member_type=str)
Args:
name (str): The name of the option - this should correspond to the key
for the option in a configuration file, e.g.
default: The default value to use. By default, this is the internal
`_NoDefault` type. If the value of this is anything other than
`_NoDefault`, this option is considered optional, so validation will
not fail if it is not present in the config. If this is left at its
default value of `_NoDefault`, then this option is considered required
and will fail validation if not present.
member_type: The type that all members of the list should have.
member_scheme (Scheme): The `Scheme` that all members of the list should
fulfil. This should be used when the list members are dictionaries/maps.
bind_env (bool): Bind the option to an environment variable.
"""
def __init__(self, name, default=_no_default, member_type=None, member_scheme=None, bind_env=False):
super(ListOption, self).__init__()
self.name = name
self.default = default
self.member_type = member_type
self.member_scheme = member_scheme
self.bind_env = bind_env
def validate(self, value):
if not isinstance(value, list):
raise errors.SchemeValidationError('{} is not a list'.format(value))
if self.member_scheme is not None and self.member_type is not None:
raise errors.SchemeValidationError(
'Cannot specify both a member_type and a member_scheme.'
)
if self.member_type is not None:
for item in value:
if type(item) != self.member_type:
raise errors.SchemeValidationError(
'Members in "{}" option are not of type {}'.format(self.name, self.member_type)
)
if self.member_scheme is not None:
if not isinstance(self.member_scheme, Scheme):
raise errors.SchemeValidationError(
'Specified member scheme is not an instance of a Scheme.'
)
for item in value:
self.member_scheme.validate(item)

206
bison/utils.py Normal file
View File

@ -0,0 +1,206 @@
# -*- coding: utf-8 -*-
"""
bison.utils
~~~~~~~~~~~
Utilities for `bison`.
"""
from bison.errors import BisonError
from bison.scheme import Option
def cast(option, value):
"""Cast a value to the type required by the option, if one is set.
This is used to cast the string values gathered from environment
variable into their required type.
Args:
option: The Option specifying the type.
value: The value to cast.
Returns:
The value casted to the expected type for the option.
"""
if not isinstance(option, Option):
raise BisonError('Unable to cast - "{}" not an Option'.format(option))
# if there is no type set for the option, return the given
# value unchanged.
if option.type is None:
return value
# cast directly
if option.type in (str, int, float):
try:
return option.type(value)
except Exception as e:
raise BisonError(
'Failed to cast {} to {}'.format(value, option.type)
) from e
# for bool, can't cast a string, since a string is truthy,
# so we need to check the value.
elif option.type == bool:
return value.lower() == 'true'
# the option type is currently not supported
else:
raise BisonError('Unsupported type for casting: {}'.format(option.type))
def build_dot_value(key, value):
"""Build new dictionaries based off of the dot notation key.
For example, if a key were 'x.y.z' and the value was 'foo',
we would expect a return value of: ('x', {'y': {'z': 'foo'}})
Args:
key (str): The key to build a dictionary off of.
value: The value associated with the dot notation key.
Returns:
tuple: A 2-tuple where the first element is the key of
the outermost scope (e.g. left-most in the dot
notation key) and the value is the constructed value
for that key (e.g. a dictionary)
"""
# if there is no nesting in the key (as specified by the
# presence of dot notation), then the key/value pair here
# are the final key value pair.
if key.count('.') == 0:
return key, value
# otherwise, we will need to construct as many dictionaries
# as there are dot components to hold the value.
final_value = value
reverse_split = key.split('.')[::-1]
end = len(reverse_split) - 1
for idx, k in enumerate(reverse_split):
if idx == end:
return k, final_value
final_value = {k: final_value}
class DotDict(dict):
"""A dictionary which supports getting and setting with dot notation keys."""
def __init__(self, dct=None):
if dct is None:
dct = {}
super(DotDict, self).__init__(dct)
def __getitem__(self, item):
# x.__getitem__(y) <==> x[y], so this makes x[y] and x.get(y) go
# through the same code path (e.g. __getattribute__)
return self.get(item, None)
def __setitem__(self, key, value):
# if there are no dots in the key, its a normal set
if key.count('.') == 0:
super(DotDict, self).__setitem__(key, value)
return
# otherwise, traverse the key components to set the value
first, remainder = key.split('.', 1)
if first in self:
v = super(DotDict, self).__getitem__(first)
v.__setitem__(remainder, value)
else:
k, v = build_dot_value(key, value)
super(DotDict, self).__setitem__(k, v)
def __contains__(self, item):
# if there are no dots in the key, it is a normal contains
if item.count('.') == 0:
return super(DotDict, self).__contains__(item)
# otherwise, traverse the key components to check contains
first, remainder = item.split('.', 1)
if first in self:
v = super(DotDict, self).__getitem__(first)
if isinstance(v, (dict, DotDict)):
return DotDict(v).__contains__(remainder)
return False
return False
# ---------------------------------------
# Public Facing Methods
# ---------------------------------------
def get(self, key, default=None):
"""Get a value from the `DotDict`.
The `key` parameter can either be a regular string key,
e.g. "foo", or it can be a string key with dot notation,
e.g. "foo.bar.baz", to signify a nested lookup.
The default value is returned if any level of the key's
components are not found.
Args:
key (str): The key to get the value for.
default: The return value should the given key
not exist in the `DotDict`.
"""
# if there are no dots in the key, its a normal get
if key.count('.') == 0:
return super(DotDict, self).get(key, default)
# set the return value to the default
value = default
# split the key into the first component and the rest of
# the components. the first component corresponds to this
# DotDict. the remainder components correspond to any nested
# DotDicts.
first, remainder = key.split('.', 1)
if first in self:
value = super(DotDict, self).get(first)
# if the value for the key at this level is a dictionary,
# then pass the remainder to that DotDict.
if isinstance(value, (dict, DotDict)):
return DotDict(value).get(remainder)
# TODO: support lists
return value
def delete(self, key):
"""Remove a value from the `DotDict`.
The `key` parameter can either be a regular string key,
e.g. "foo", or it can be a string key with dot notation,
e.g. "foo.bar.baz", to signify a nested element.
If the key does not exist in the `DotDict`, it will continue
silently.
Args:
key (str): The key to remove.
"""
dct = self
keys = key.split('.')
last_key = keys[-1]
for k in keys:
# if the key is the last one, e.g. 'z' in 'x.y.z', try
# to delete it from its dict.
if k == last_key:
del dct[k]
break
# if the dct is a DotDict, get the value for the key `k` from it.
if isinstance(dct, DotDict):
dct = super(DotDict, dct).__getitem__(k)
# otherwise, just get the value from the default __getitem__
# implementation.
else:
dct = dct.__getitem__(k)
if not isinstance(dct, (DotDict, dict)):
raise KeyError(
'Subkey "{}" in "{}" invalid for deletion'.format(k, key)
)

12
example/app.yaml Normal file
View File

@ -0,0 +1,12 @@
log: debug
port: 5000
settings:
requests:
timeout: 3
backends:
- host: 10.1.2.3
port: 5001
- host: 10.1.2.4
port: 5013
- host: 10.1.2.5
port: 5044

52
example/example.py Normal file
View File

@ -0,0 +1,52 @@
"""
A (contrived) example of how to use bison
"""
import bison
# the scheme for the configuration. this allows us to set defaults
# and validate configuration data
config_scheme = bison.Scheme(
bison.Option('log', default='info', choices=['debug', 'info', 'warn', 'error']),
bison.Option('port', field_type=int),
bison.DictOption('settings', scheme=bison.Scheme(
bison.DictOption('requests', scheme=bison.Scheme(
bison.Option('timeout', field_type=int)
))
)),
bison.ListOption('backends', member_scheme=bison.Scheme(
bison.Option('host', field_type=str),
bison.Option('port', field_type=int)
))
)
# create a new Bison instance to store and manage configuration data
config = bison.Bison(scheme=config_scheme)
# set the config file name to 'app' (default is 'config') and set the
# search paths to '.' and '/tmp/app/config'
config.config_name = 'app'
config.add_config_paths('.', '/tmp/app/config')
# set the environment variable prefix and enable auto-env
config.env_prefix = 'MY_APP'
config.auto_env = True
# finally, parse the config sources to build the unified configuration
config.parse()
# now, for examples sake, lets see how data can be accessed
print('Unified configuration: {}'.format(config.config))
print('Getting values:')
print('\tlog: {}'.format(config.get('log')))
print('\tport: {}'.format(config.get('port')))
print('\tsettings: {}'.format(config.get('settings')))
print('\tsettings.requests.timeout: {}'.format(config.get('settings.requests.timeout')))
print('Setting values:')
print('\tlog (pre): {}'.format(config.get('log')))
config.set('log', 'warn')
print('\tlog (post): {}'.format(config.get('log')))

5
setup.cfg Normal file
View File

@ -0,0 +1,5 @@
[bdist_wheel]
universal = 1
[metadata]
license_file = LICENSE

56
setup.py Normal file
View File

@ -0,0 +1,56 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
from setuptools import find_packages, setup
here = os.path.abspath(os.path.dirname(__file__))
# Load the package's __version__.py module as a dictionary.
pkg = {}
with open(os.path.join(here, 'bison', '__version__.py')) as f:
exec(f.read(), pkg)
# Load the README
with open('README.md', 'r') as f:
readme = f.read()
setup(
name=pkg['__title__'],
version=pkg['__version__'],
description=pkg['__description__'],
long_description=readme,
author=pkg['__author__'],
author_email=pkg['__author_email__'],
url=pkg['__url__'],
license=pkg['__license__'],
packages=find_packages(),
package_data={'': ['LICENSE']},
package_dir={'bison': 'bison'},
include_package_data=True,
python_requires=">=3.4",
install_requires=[
'pyyaml'
],
zip_safe=False,
classifiers=[
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
'Natural Language :: English',
'Programming Language :: Python',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: Implementation :: CPython',
'Programming Language :: Python :: Implementation :: PyPy',
'Topic :: Software Development :: Libraries',
'Topic :: Software Development :: Libraries :: Python Modules'
],
tests_require=[
'pytest>=2.8.0',
'pytest-cov'
]
)

0
tests/__init__.py Normal file
View File

52
tests/conftest.py Normal file
View File

@ -0,0 +1,52 @@
"""Test fixtures for Bison unit tests."""
import os
import pytest
@pytest.fixture()
def yaml_config(tmpdir):
"""Create a YAML config file."""
cfg = tmpdir.join('config.yml')
cfg.write("""
foo: True
bar:
baz: 1
test: value
""")
yield cfg
cfg.remove()
@pytest.fixture()
def bad_yaml_config(tmpdir):
"""Create a bad YAML config file."""
cfg = tmpdir.join('config.yml')
cfg.write("""
field:\n::>:!~-:
""")
yield cfg
cfg.remove()
@pytest.fixture()
def with_env():
"""Set and cleanup environment variables for tests."""
os.environ['TEST_ENV_FOO'] = 'bar'
os.environ['TEST_ENV_NESTED_ENV_KEY'] = 'test'
os.environ['TEST_OTHER_ENV_BAR'] = 'baz'
os.environ['FOO_INT'] = '1'
os.environ['FOO_BOOL'] = 'False'
yield
del os.environ['TEST_ENV_FOO']
del os.environ['TEST_ENV_NESTED_ENV_KEY']
del os.environ['TEST_OTHER_ENV_BAR']
del os.environ['FOO_INT']
del os.environ['FOO_BOOL']

466
tests/test_bison.py Normal file
View File

@ -0,0 +1,466 @@
"""Unit tests for bison.bison"""
import os
import pytest
import bison
from bison import errors
from bison.bison import YAML
class TestBison:
"""Test for the `Bison` class."""
def test_simple_init(self):
"""Initialize a Bison object."""
b = bison.Bison()
assert b.scheme is None
assert b.config_name == 'config'
assert b.config_paths == []
assert b.config_file is None
assert b.env_prefix is None
assert b.auto_env is False
assert b._full_config is None
assert isinstance(b._default, bison.DotDict)
assert isinstance(b._config, bison.DotDict)
assert isinstance(b._environment, bison.DotDict)
assert isinstance(b._override, bison.DotDict)
assert len(b._default) == 0
assert len(b._config) == 0
assert len(b._environment) == 0
assert len(b._override) == 0
def test_config_property_empty(self):
"""Get the full configuration when nothing is set."""
b = bison.Bison()
assert b._full_config is None
c = b.config
assert isinstance(c, bison.DotDict)
assert len(c) == 0
assert b._full_config == c
@pytest.mark.parametrize(
'key,expected,config', [
('foo', None, None),
('foo', None, bison.DotDict()),
('foo', None, bison.DotDict({'foo': None})),
('foo', 'bar', bison.DotDict({'foo': 'bar'})),
('foo.bar', 'baz', bison.DotDict({'foo': {'bar': 'baz'}})),
('foo.bar.baz', 1, bison.DotDict({'foo': {'bar': {'baz': 1}}})),
]
)
def test_get(self, key, expected, config):
"""Get configuration values from Bison."""
b = bison.Bison()
b._full_config = config # for the test, set the config manually
value = b.get(key)
assert value == expected
@pytest.mark.parametrize(
'key,value', [
('foo', 'bar'),
('foo', 1),
('foo', False),
('foo', True),
('foo', None),
('foo.bar', 'baz'),
('foo.bar.baz', 1),
]
)
def test_set(self, key, value):
"""Set configuration overrides for a Bison instance."""
b = bison.Bison()
assert len(b._override) == 0
assert b.get(key) is None
b.set(key, value)
assert len(b._override) == 1
assert b.get(key) == value
@pytest.mark.parametrize(
'paths', [
(),
('path1',),
('path1', 'path2'),
('path1', 'path2', 'path3')
]
)
def test_add_config_paths(self, paths):
"""Add configuration paths to the Bison instance."""
b = bison.Bison()
assert len(b.config_paths) == 0
b.add_config_paths(*paths)
assert len(b.config_paths) == len(paths)
def test_validate_no_scheme(self):
"""Validate the Bison configuration when there is no Scheme to validate against."""
b = bison.Bison()
b.set('foo', 'bar')
b.validate()
def test_validate_ok(self):
"""Validate the Bison configuration successfully."""
b = bison.Bison(scheme=bison.Scheme(
bison.Option('foo', field_type=str)
))
# add 'foo' to the config
b.set('foo', 'bar')
# validation should succeed -- the value of 'foo' is a string
b.validate()
def test_validate_fail(self):
"""Validate the Bison configuration unsuccessfully."""
b = bison.Bison(scheme=bison.Scheme(
bison.Option('foo', field_type=str)
))
# add 'foo' to the config
b.set('foo', 1)
# validation should fail -- the value of 'foo' is not a string
with pytest.raises(errors.SchemeValidationError):
b.validate()
def test_parse_no_sources(self):
"""Parse when there are no sources to parse from."""
b = bison.Bison()
b.parse()
assert len(b.config) == 0
def test_find_config(self, yaml_config):
"""Find a config file when it does exist"""
b = bison.Bison()
b.config_name = 'config'
b.config_format = YAML
b.config_paths = ['.', yaml_config.dirname]
assert b.config_file is None
b._find_config()
assert b.config_file == os.path.join(yaml_config.dirname, yaml_config.basename)
def test_find_config_nonexistent(self):
"""Find a config file when it does not exist"""
b = bison.Bison()
b.config_name = 'config'
b.config_format = YAML
b.config_paths = ['.']
assert b.config_file is None
with pytest.raises(errors.BisonError):
b._find_config()
def test_parse_config_no_paths(self):
"""Parse the file config when no paths are specified"""
b = bison.Bison()
assert b.config_file is None
assert len(b._config) == 0
b._parse_config()
assert b.config_file is None
assert len(b._config) == 0
def test_parse_config_ok(self, yaml_config):
"""Parse the file config successfully."""
b = bison.Bison()
b.add_config_paths(yaml_config.dirname)
assert b.config_file is None
assert len(b._config) == 0
b._parse_config()
assert b.config_file == os.path.join(yaml_config.dirname, yaml_config.basename)
assert len(b._config) == 2
def test_parse_config_fail(self, bad_yaml_config):
"""Parse the file config unsuccessfully."""
b = bison.Bison()
b.add_config_paths(bad_yaml_config.dirname)
assert b.config_file is None
assert len(b._config) == 0
with pytest.raises(errors.BisonError):
b._parse_config()
assert b.config_file == os.path.join(bad_yaml_config.dirname, bad_yaml_config.basename)
assert len(b._config) == 0
def test_parse_defaults_no_scheme(self):
"""Parse the defaults when there is no Scheme."""
b = bison.Bison()
assert len(b._default) == 0
assert b._full_config is None
b._parse_default()
assert len(b._default) == 0
assert b._full_config is None
def test_parse_defaults_ok(self):
"""Parse the defaults successfully."""
b = bison.Bison(scheme=bison.Scheme(
bison.Option('foo', default='bar')
))
assert len(b._default) == 0
assert len(b.config) == 0
b._parse_default()
assert len(b._default) == 1
assert b.config == {'foo': 'bar'}
def test_parse_env_env_prefix(self, with_env):
"""Parse the environment variable configuration."""
b = bison.Bison()
b.env_prefix = 'TEST_ENV_'
b.auto_env = True
assert len(b._environment) == 0
assert len(b.config) == 0
b._parse_env()
# no scheme means nothing parsed.
assert len(b._environment) == 0
assert len(b.config) == 0
def test_parse_env_env_prefix2(self, with_env):
"""Parse the environment variable configuration."""
b = bison.Bison()
# here, do not include the trailing '_', this should be added
# on automatically if not there.
b.env_prefix = 'TEST_ENV'
b.auto_env = True
assert len(b._environment) == 0
assert len(b.config) == 0
b._parse_env()
# no scheme means nothing parsed.
assert len(b._environment) == 0
assert len(b.config) == 0
def test_parse_env_bind_env(self, with_env):
"""Parse the environment variable configuration."""
b = bison.Bison(scheme=bison.Scheme(
bison.Option('foo', bind_env='FOO')
))
b.env_prefix = 'TEST_ENV'
assert len(b._environment) == 0
assert len(b.config) == 0
b._parse_env()
# we should get nothing back here -- env parsing will NOT
# use the env_prefix when bind_env is specified manually.
assert len(b._environment) == 0
assert len(b.config) == 0
def test_parse_env_bind_env_no_prefix(self, with_env):
"""Parse the environment variable configuration."""
b = bison.Bison(scheme=bison.Scheme(
bison.Option('foo', bind_env='TEST_ENV_FOO')
))
assert len(b._environment) == 0
assert len(b.config) == 0
b._parse_env()
assert len(b._environment) == 1
assert b.config == {
'foo': 'bar'
}
def test_parse_env_bind_env_auto(self, with_env):
"""Parse the environment variable configuration."""
b = bison.Bison(scheme=bison.Scheme(
# this will autoenv to TEST_ENV_FOO, so we set to something
# different here to test that it a.) works, b.) overrides autoenv
bison.Option('foo', bind_env='TEST_OTHER_ENV_BAR'),
bison.DictOption('nested', scheme=bison.Scheme(
bison.DictOption('env', scheme=bison.Scheme(
bison.Option('key')
))
))
))
b.env_prefix = 'TEST_ENV'
b.auto_env = True
assert len(b._environment) == 0
assert len(b.config) == 0
b._parse_env()
assert len(b._environment) == 2
assert b.config == {
'foo': 'baz',
'nested': {
'env': {
'key': 'test'
}
}
}
def test_parse_env_bind_env_false(self, with_env):
"""Parse the environment variable configuration."""
b = bison.Bison(scheme=bison.Scheme(
bison.Option('foo', bind_env=False)
))
b.env_prefix = 'TEST_ENV'
assert len(b._environment) == 0
assert len(b.config) == 0
b._parse_env()
# we should get nothing back here -- nothing configured for
# env parsing
assert len(b._environment) == 0
assert len(b.config) == 0
def test_parse_env_bind_env_false_no_prefix(self, with_env):
"""Parse the environment variable configuration."""
b = bison.Bison(scheme=bison.Scheme(
bison.Option('foo', bind_env=False)
))
assert len(b._environment) == 0
assert len(b.config) == 0
b._parse_env()
# we should get nothing back here -- nothing configured for
# env parsing
assert len(b._environment) == 0
assert len(b.config) == 0
def test_parse_env_bind_env_false_auto(self, with_env):
"""Parse the environment variable configuration."""
b = bison.Bison(scheme=bison.Scheme(
bison.Option('foo', bind_env=False)
))
b.env_prefix = 'TEST_ENV'
b.auto_env = True
assert len(b._environment) == 0
assert len(b.config) == 0
b._parse_env()
# we should not get back the 'foo' key since its disabled,
# even with auto-env
assert len(b._environment) == 0
assert len(b.config) == 0
def test_parse_env_bind_env_true(self, with_env):
"""Parse the environment variable configuration."""
b = bison.Bison(scheme=bison.Scheme(
bison.Option('foo', bind_env=True)
))
b.env_prefix = 'TEST_ENV'
assert len(b._environment) == 0
assert len(b.config) == 0
b._parse_env()
assert len(b._environment) == 1
assert b.config == {
'foo': 'bar'
}
def test_parse_env_bind_env_true_no_prefix(self, with_env):
"""Parse the environment variable configuration."""
b = bison.Bison(scheme=bison.Scheme(
bison.Option('foo', bind_env=True)
))
assert len(b._environment) == 0
assert len(b.config) == 0
b._parse_env()
# we should not get anything back since we don't have a 'FOO' env variable
assert len(b._environment) == 0
assert len(b.config) == 0
def test_parse_env_bind_env_true_auto(self, with_env):
"""Parse the environment variable configuration."""
b = bison.Bison(scheme=bison.Scheme(
bison.Option('foo', bind_env=True),
bison.DictOption('nested', scheme=bison.Scheme(
bison.DictOption('env', scheme=bison.Scheme(
bison.Option('key')
))
))
))
b.env_prefix = 'TEST_ENV'
b.auto_env = True
assert len(b._environment) == 0
assert len(b.config) == 0
b._parse_env()
assert len(b._environment) == 2
assert b.config == {
'foo': 'bar',
'nested': {
'env': {
'key': 'test'
}
}
}
def test_parse_env_int_value(self, with_env):
"""Parse the environment variable configuration."""
b = bison.Bison(scheme=bison.Scheme(
bison.Option('foo', bind_env='FOO_INT', field_type=int)
))
assert len(b._environment) == 0
assert len(b.config) == 0
b._parse_env()
assert len(b._environment) == 1
assert b.config == {
'foo': 1,
}
def test_parse_env_bool_value(self, with_env):
"""Parse the environment variable configuration."""
b = bison.Bison(scheme=bison.Scheme(
bison.Option('foo', bind_env='FOO_BOOL', field_type=bool)
))
assert len(b._environment) == 0
assert len(b.config) == 0
b._parse_env()
assert len(b._environment) == 1
assert b.config == {
'foo': False,
}

569
tests/test_scheme.py Normal file
View File

@ -0,0 +1,569 @@
"""Unit tests for bison.scheme"""
import pytest
from bison import errors, scheme
def test_base_opt():
"""Validate the base option, which should fail."""
opt = scheme._BaseOpt()
with pytest.raises(NotImplementedError):
opt.validate('test-data')
class TestOption:
"""Tests for the `Option` class."""
def test_init_simple(self):
"""Initialize an Option."""
opt = scheme.Option('test-opt')
assert opt.name == 'test-opt'
assert type(opt.default) == scheme.NoDefault
assert opt.type is None
assert opt.choices is None
assert opt.bind_env is None
def test_init_full(self):
"""Initialize an Option."""
opt = scheme.Option(
name='test-opt',
default='foo',
field_type=str,
choices=['foo', 'bar'],
bind_env=True
)
assert opt.name == 'test-opt'
assert type(opt.default) != scheme.NoDefault
assert opt.default == 'foo'
assert opt.type == str
assert opt.choices == ['foo', 'bar']
assert opt.bind_env is True
@pytest.mark.parametrize(
'field_type,value', [
(str, 'test-value'),
(str, ''),
(int, 0),
(int, 1000),
(int, -1),
(float, 0.0),
(float, 1000.999),
(float, -1.0),
(bool, True),
(bool, False),
(list, []),
(list, [1, 2, 3]),
(list, ['a', 'b', 'c']),
(tuple, tuple()),
(tuple, (1,)),
(tuple, ('a', 'b')),
(dict, {}),
(dict, {'a': 'b'}),
(dict, {1: 2})
]
)
def test_validate_type_ok(self, field_type, value):
"""Validate an Option, where type validation succeeds"""
opt = scheme.Option('test-option', field_type=field_type)
opt.validate(value)
@pytest.mark.parametrize(
'field_type,value', [
(str, None),
(str, 1),
(str, 1.8),
(str, True),
(str, ['a', 'b', 'c']),
(int, None),
(int, 'value'),
(int, 1.8),
(int, True),
(int, ['a', 'b', 'c']),
(float, None),
(float, 'value'),
(float, 1),
(float, True),
(float, ['a', 'b', 'c']),
(bool, None),
(bool, 'value'),
(bool, 1),
(bool, 1.8),
(bool, ['a', 'b', 'c']),
(list, None),
(list, 'value'),
(list, 1),
(list, 1.8),
(list, True),
]
)
def test_validate_type_failure(self, field_type, value):
"""Validate an Option, where type validation fails"""
opt = scheme.Option('test-option', field_type=field_type)
with pytest.raises(errors.SchemeValidationError):
opt.validate(value)
@pytest.mark.parametrize(
'choices,value', [
(['one', 'two'], 'one'), # choices can be lists
(('one', 'two'), 'one'), # choices can be tuples
([1, 2, 3], 1),
((1, 2, 3), 1),
([None], None),
((None,), None),
([1.21, 1.22, 1.23], 1.23),
((1.21, 1.22, 1.23), 1.23),
([True, False], False),
((True, False), False)
]
)
def test_validate_choices_ok(self, choices, value):
"""Validate an Option, where choice validation succeeds"""
opt = scheme.Option('test-option', choices=choices)
opt.validate(value)
@pytest.mark.parametrize(
'choices,value', [
(['one', 'two'], 'three'),
([1, 2, 3], 0),
([], None),
([0.2, 0.3, 0.4], 0.1),
([False], True)
]
)
def test_validate_choices_failure(self, choices, value):
"""Validate an Option, where choice validation succeeds"""
opt = scheme.Option('test-option', choices=choices)
with pytest.raises(errors.SchemeValidationError):
opt.validate(value)
class TestDictOption:
"""Tests for the `DictOption` class."""
def test_init_simple(self):
"""Initialize a DictOption."""
opt = scheme.DictOption('test-opt', None)
assert opt.name == 'test-opt'
assert type(opt.default) == scheme.NoDefault
assert opt.scheme is None
assert opt.bind_env is False
def test_init_full(self):
"""Initialize a DictOption."""
opt = scheme.DictOption(
name='test-opt',
scheme=scheme.Scheme(),
default='foo',
bind_env=True
)
assert opt.name == 'test-opt'
assert type(opt.default) != scheme.NoDefault
assert opt.default == 'foo'
assert isinstance(opt.scheme, scheme.Scheme)
assert opt.bind_env is True
@pytest.mark.parametrize(
'value', [
'foo',
1,
1.234,
False,
True,
None,
[1, 2, 3],
['a', 'b', 'c'],
[{'a': 1}, {'b': 2}],
('foo', 'bar'),
{1, 2, 3}
]
)
def test_validate_bad_data(self, value):
"""Validate a DictOption where the given value is not a dict"""
opt = scheme.DictOption('test-opt', scheme.Scheme())
with pytest.raises(errors.SchemeValidationError):
opt.validate(value)
def test_validate_no_scheme(self):
"""Validate a DictOption with no scheme"""
opt = scheme.DictOption('test-opt', None)
opt.validate({'foo': 'bar'})
def test_validate_with_scheme(self):
"""Validate a DictOption with a scheme"""
opt = scheme.DictOption('test-opt', scheme.Scheme(
scheme.Option('foo', field_type=str)
))
opt.validate({'foo': 'bar'})
class TestListOption:
"""Tests for the `ListOption` class."""
def test_init_simple(self):
"""Initialize a ListOption."""
opt = scheme.ListOption('test-opt')
assert opt.name == 'test-opt'
assert type(opt.default) == scheme.NoDefault
assert opt.member_type is None
assert opt.member_scheme is None
assert opt.bind_env is False
def test_init_full(self):
"""Initialize a ListOption."""
opt = scheme.ListOption(
name='test-opt',
default='foo',
member_type=dict,
member_scheme=scheme.Scheme(),
bind_env=True
)
assert opt.name == 'test-opt'
assert type(opt.default) != scheme.NoDefault
assert opt.default == 'foo'
assert opt.member_type == dict
assert isinstance(opt.member_scheme, scheme.Scheme)
assert opt.bind_env is True
@pytest.mark.parametrize(
'value', [
'foo',
1,
1.234,
False,
True,
None,
{'a': 1, 'b': 2},
('foo', 'bar'),
{1, 2, 3}
]
)
def test_validate_bad_data(self, value):
"""Validate when the value is not a list"""
opt = scheme.ListOption('test-opt')
with pytest.raises(errors.SchemeValidationError):
opt.validate(value)
def test_validate_member_type_scheme_conflict(self):
"""Validate the ListOption when both member_type and member_scheme are defined."""
opt = scheme.ListOption(
name='test-opt',
member_type=int,
member_scheme=scheme.Scheme()
)
with pytest.raises(errors.SchemeValidationError):
opt.validate([1, 2, 3])
@pytest.mark.parametrize(
'member_type,value', [
(str, ['a', 'b', 'c']),
(int, [1, 2, 3]),
(float, [1.0, 2.0, 3.0]),
(bool, [False, False, True]),
(tuple, [(1,), (2,), (3,)]),
(list, [[1], [2], [3]]),
(dict, [{'a': 1, 'b': 2}])
]
)
def test_validate_member_type_ok(self, member_type, value):
"""Validate the ListOption, where member_type validation succeeds."""
opt = scheme.ListOption('test-opt', member_type=member_type)
opt.validate(value)
@pytest.mark.parametrize(
'member_type,value', [
(str, ['a', 1]),
(str, [1, 2]),
(int, [1, 2, '3']),
(int, ['foo', 'bar']),
(float, [1.0, '2.0', 3.0]),
(float, ['foo', 'bar']),
(bool, ['False', False, True]),
(bool, ['foo', 'bar']),
(tuple, [(1,), '(2,)', (3,)]),
(tuple, ['']),
(list, [[1], (2,), [3]]),
(list, ['foo', 'bar']),
(dict, [{'a': 1}, {1, 2, 3}]),
(dict, ['foo', 'bar'])
]
)
def test_validate_member_type_failure(self, member_type, value):
"""Validate the ListOption, where member_type validation fails."""
opt = scheme.ListOption('test-opt', member_type=member_type)
with pytest.raises(errors.SchemeValidationError):
opt.validate(value)
@pytest.mark.parametrize(
'member_scheme,value', [
# an empty scheme will validate every dict as correct
(scheme.Scheme(), [{'foo': 'bar'}]),
(scheme.Scheme(), [{1: 3}]),
(scheme.Scheme(), [{1.23: 2.31}]),
(scheme.Scheme(), [{False: True}]),
(scheme.Scheme(), [{None: None}]),
(scheme.Scheme(), [{(1, 2): (2, 1)}]),
(scheme.Scheme(scheme.Option('foo', field_type=str)), [{'foo': 'bar'}]),
(scheme.Scheme(scheme.Option('foo', field_type=str)), [{'foo': 'baz'}]),
(scheme.Scheme(scheme.Option('foo', field_type=str)), [{'foo': 'baz'}, {'foo': 'bar'}])
]
)
def test_validate_member_scheme_ok(self, member_scheme, value):
"""Validate the ListOption, where member_scheme validation succeeds."""
opt = scheme.ListOption('test-opt', member_scheme=member_scheme)
opt.validate(value)
@pytest.mark.parametrize(
'member_scheme,value', [
(scheme.Scheme(scheme.Option('foo', field_type=str)), [{'foo': 1}]),
(scheme.Scheme(scheme.Option('foo', field_type=str)), [{'foo': 1.23}]),
(scheme.Scheme(scheme.Option('foo', field_type=str)), [{'foo': False}]),
(scheme.Scheme(scheme.Option('foo', field_type=str)), [{'foo': True}]),
(scheme.Scheme(scheme.Option('foo', field_type=str)), [{'foo': None}]),
(scheme.Scheme(scheme.Option('foo', field_type=str)), [{'foo': (1, 2)}]),
(scheme.Scheme(scheme.Option('foo', field_type=str)), [{'foo': ['a', 'b']}]),
(scheme.Scheme(scheme.Option('foo', field_type=str)), [{'foo': {'a', 'b'}}]),
(scheme.Scheme(scheme.Option('bar', field_type=int)), [{'bar': 'foo'}]),
(scheme.Scheme(scheme.Option('bar', field_type=int)), [{'bar': 1.23}]),
(scheme.Scheme(scheme.Option('bar', field_type=int)), [{'bar': False}]),
(scheme.Scheme(scheme.Option('bar', field_type=int)), [{'bar': True}]),
(scheme.Scheme(scheme.Option('bar', field_type=int)), [{'bar': None}]),
(scheme.Scheme(scheme.Option('bar', field_type=int)), [{'bar': (1, 2)}]),
(scheme.Scheme(scheme.Option('bar', field_type=int)), [{'bar': ['a', 'b']}]),
(scheme.Scheme(scheme.Option('bar', field_type=int)), [{'bar': {'a', 'b'}}])
]
)
def test_validate_member_scheme_fail(self, member_scheme, value):
"""Validate the ListOption, where member_scheme validation fails."""
opt = scheme.ListOption('test-opt', member_scheme=member_scheme)
with pytest.raises(errors.SchemeValidationError):
opt.validate(value)
def test_validate_member_scheme_not_a_scheme(self):
"""Validate the ListOption, where the member_scheme is not a Scheme."""
opt = scheme.ListOption('test-opt', member_scheme='not-none-or-scheme')
with pytest.raises(errors.SchemeValidationError):
opt.validate(['a', 'b', 'c'])
class TestScheme:
"""Tests for the `Scheme` class."""
def test_empty_init(self):
"""Initialize a Scheme with no arguments."""
sch = scheme.Scheme()
assert len(sch.args) == 0
assert sch._flat is None
def test_single_arg_init(self):
"""Initialize a Scheme with one argument."""
sch = scheme.Scheme(
'item'
)
assert len(sch.args) == 1
assert sch._flat is None
def test_multi_arg_init(self):
"""Initialize a Scheme with multiple arguments."""
sch = scheme.Scheme(
'item-1',
'item-2',
'item-3'
)
assert len(sch.args) == 3
assert sch._flat is None
@pytest.mark.parametrize(
'args,expected', [
(
# args
(scheme.Option('foo', default='bar'),),
# expected
{'foo': 'bar'}
),
(
# args
(
scheme.Option('foo'),
scheme.Option('bar', default='baz'),
scheme.ListOption('list', default=['a', 'b'])
),
# expected
{
'bar': 'baz',
'list': ['a', 'b']
}
),
(
# args
(
scheme.DictOption('foo', scheme=scheme.Scheme(), default={}),
scheme.DictOption('bar', scheme=scheme.Scheme(
scheme.Option('test', default=True),
scheme.Option('data', default=None),
scheme.Option('value', default=20),
scheme.Option('float', default=10.1010),
scheme.Option('no_default'),
scheme.DictOption('dct', scheme=scheme.Scheme(
scheme.Option('nested', default='here')
))
))
),
# expected
{
'foo': {},
'bar': {
'test': True,
'data': None,
'value': 20,
'float': 10.1010,
'dct': {
'nested': 'here'
}
}
}
),
]
)
def test_build_defaults(self, args, expected):
"""Build a defaults dict from a Scheme."""
sch = scheme.Scheme(*args)
defaults = sch.build_defaults()
assert defaults == expected
@pytest.mark.parametrize(
'args', [
('a', 'b'), # not an instance of _BaseOpt
]
)
def test_build_defaults_failure(self, args):
"""Build a defaults dict from a Scheme with bad data."""
sch = scheme.Scheme(*args)
with pytest.raises(errors.InvalidSchemeError):
sch.build_defaults()
@pytest.mark.parametrize(
'args,expected', [
(
(scheme.Option('foo'),),
['foo']
),
(
(scheme.Option('foo'), scheme.Option('bar')),
['foo', 'bar']
),
(
(
scheme.Option('foo'),
scheme.DictOption('bar', scheme=scheme.Scheme(
scheme.Option('test'),
scheme.DictOption('dct', scheme=scheme.Scheme(
scheme.Option('nested')
)),
scheme.ListOption('list')
))
),
['foo', 'bar', 'bar.test', 'bar.dct', 'bar.list', 'bar.dct.nested']
)
]
)
def test_flatten(self, args, expected):
"""Flatten a Scheme."""
sch = scheme.Scheme(*args)
flattened = sch.flatten()
assert len(flattened) == len(expected)
for key in expected:
assert key in flattened
@pytest.mark.parametrize(
'args,value', [
(
# option exists in config
(scheme.Option('foo', default='bar', field_type=str),),
{'foo': 'baz'}
),
(
# option does not exist in config, but has default
(scheme.Option('foo', default='bar', field_type=str),),
{}
),
(
# multiple args
(
scheme.Option('foo', field_type=str),
scheme.Option('bar', field_type=int),
scheme.Option('baz', choices=['test'])
),
{'foo': 'a', 'bar': 1, 'baz': 'test'}
)
]
)
def test_validate_ok(self, args, value):
"""Validate a Scheme successfully."""
sch = scheme.Scheme(*args)
sch.validate(value)
@pytest.mark.parametrize(
'args,value', [
(
# option does not exist in config, no default
(scheme.Option('foo', field_type=str),),
{}
),
(
# option exists in config, fails validation
(scheme.Option('foo', default='bar', field_type=str),),
{'foo': 1}
),
(
# multiple args, one fails validation
(
scheme.Option('foo', field_type=str),
scheme.Option('bar', field_type=int),
scheme.Option('baz', choices=['test'])
),
{'foo': 'a', 'bar': 1, 'baz': 'something'}
)
]
)
def test_validate_failure(self, args, value):
"""Validate a Scheme unsuccessfully."""
sch = scheme.Scheme(*args)
with pytest.raises(errors.SchemeValidationError):
sch.validate(value)
@pytest.mark.parametrize(
'value', [
'foo',
1,
1.23,
['a', 'b', 'c'],
{'a', 'b', 'c'},
('a', 'b', 'c'),
None,
False,
True
]
)
def test_validate_failure_bad_config(self, value):
"""Validate a Scheme where the given config is not a dict."""
sch = scheme.Scheme()
with pytest.raises(errors.SchemeValidationError):
sch.validate(value)

270
tests/test_utils.py Normal file
View File

@ -0,0 +1,270 @@
"""Unit tests for bison.utils"""
import pytest
from bison import errors, scheme, utils
@pytest.mark.parametrize(
'option,value,expected', [
(scheme.Option('foo'), 'foo', 'foo'),
(scheme.Option('foo'), 1, 1),
(scheme.Option('foo'), None, None),
(scheme.Option('foo'), False, False),
(scheme.Option('foo', field_type=str), 'foo', 'foo'),
(scheme.Option('foo', field_type=str), 1, '1'),
(scheme.Option('foo', field_type=int), '1', 1),
(scheme.Option('foo', field_type=float), '1', 1.0),
(scheme.Option('foo', field_type=float), '1.23', 1.23),
(scheme.Option('foo', field_type=bool), 'false', False),
(scheme.Option('foo', field_type=bool), 'False', False),
(scheme.Option('foo', field_type=bool), 'FALSE', False),
(scheme.Option('foo', field_type=bool), 'true', True),
(scheme.Option('foo', field_type=bool), 'True', True),
(scheme.Option('foo', field_type=bool), 'TRUE', True),
]
)
def test_cast(option, value, expected):
"""Cast values to the type set by the Option."""
actual = utils.cast(option, value)
assert actual == expected
@pytest.mark.parametrize(
'option,value', [
(scheme.Option('foo', field_type=int), 'foo'),
(scheme.ListOption('foo'), 'foo'),
(scheme.Option('foo', field_type=list), 'foo'),
(scheme.Option('foo', field_type=tuple), 'foo'),
]
)
def test_cast_fail(option, value):
"""Cast values to the type set by the Option."""
with pytest.raises(errors.BisonError):
utils.cast(option, value)
@pytest.mark.parametrize(
'key,value,expected', [
('a', 'b', ('a', 'b')),
('a.b', 'c', ('a', {'b': 'c'})),
('a.b.c', None, ('a', {'b': {'c': None}})),
('a.b.c', 1, ('a', {'b': {'c': 1}})),
('a.b.c', True, ('a', {'b': {'c': True}}))
]
)
def test_build_dot_value(key, value, expected):
"""Test building new dictionaries based off of a dot notation key"""
res = utils.build_dot_value(key, value)
assert res == expected
class TestDotDict:
"""Tests for the DotDict class."""
@pytest.mark.parametrize(
'param,expected', [
(None, {}),
({}, {}),
({'a': 'b'}, {'a': 'b'}),
({'a': 1, 'b': {'c': None}}, {'a': 1, 'b': {'c': None}})
]
)
def test_init(self, param, expected):
"""Initialize a new DotDict."""
dd = utils.DotDict(param)
assert dd == expected
@pytest.mark.parametrize(
'key,expected', [
('z', None),
('x.y.z', None),
('c.d.g', None),
('', None),
('a', 1),
('b', None),
('c', {'f': 'bar', 'd': {'e': 'foo'}}),
('c.d', {'e': 'foo'}),
('c.d.e', 'foo'),
('c.f', 'bar'),
('g', False)
]
)
def test_get(self, key, expected):
"""Get values from the DotDict."""
dd = utils.DotDict({
'a': 1,
'b': None,
'c': {
'd': {
'e': 'foo'
},
'f': 'bar'
},
'g': False
})
# test getting values via the .get() method
value = dd.get(key)
assert value == expected
# test getting values via dictionary access methods
value = dd[key]
assert value == expected
@pytest.mark.parametrize(
'key', [
'a',
'c',
'c.d',
'c.d.e',
'c.f'
]
)
def test_delete_1(self, key):
"""Delete values from the DotDict that exist."""
dd = utils.DotDict({
'a': 1,
'c': {
'd': {
'e': 'foo'
},
'f': 'bar'
}
})
value = dd.get(key)
assert value is not None
# delete via the .delete() method
dd.delete(key)
value = dd.get(key)
assert value is None
@pytest.mark.parametrize(
'key', [
'a',
'c'
]
)
def test_delete_2(self, key):
"""Delete values from the DotDict using del"""
dd = utils.DotDict({
'a': 1,
'c': {
'd': {
'e': 'foo'
},
'f': 'bar'
}
})
value = dd[key]
assert value is not None
# delete via the del keyword. since we are testing
# first order keys, they should all resolve, so they
# can be deleted successfully.
del dd[key]
value = dd[key]
assert value is None
@pytest.mark.parametrize(
'key', [
'x',
'x.y.z',
'',
'c.e',
'c.d.e',
'c.d.e.f'
]
)
def test_delete_3(self, key):
"""Delete values that do not exist from the DotDict."""
dd = utils.DotDict({
'a': 1,
'b': None,
'c': {
'd': 'foo',
'f': 'bar'
}
})
# delete via the .delete() method
with pytest.raises(KeyError):
dd.delete(key)
@pytest.mark.parametrize(
'key', [
'c.d',
'c.d.e',
'c.f'
]
)
def test_delete_4(self, key):
"""Delete values from the DotDict using del"""
dd = utils.DotDict({
'a': 1,
'c': {
'd': {
'e': 'foo'
},
'f': 'bar'
}
})
# delete via the del keyword. dot notation keys
# should fail resolution and raise a KeyError
with pytest.raises(KeyError):
del dd[key]
@pytest.mark.parametrize(
'key,value', [
('a', 'foo'),
('b', 'bar'),
('b.c', False),
('x', 1),
('x.y', [1, 2, 3]),
('x.y.z', 'baz'),
('x.y.z.q', {'a': 'foo', 'b': [1, 2, 3]})
]
)
def test_set(self, key, value):
"""Set an item in the DotDict"""
dd = utils.DotDict({
'a': 1,
'b': {'c': True}
})
dd[key] = value
assert dd.get(key) == value
@pytest.mark.parametrize(
'key,expected', [
('a', True),
('a.b', False),
('a.b.c', False),
('b', True),
('b.c', True),
('b.c', True),
('b.a', False),
('b.c.d', True),
('b.c.d.e', False),
('b.c.d.e.f', False),
('g', False),
('g.e', False),
]
)
def test_inclusion(self, key, expected):
"""Check if a key exists in a DotDict"""
dd = utils.DotDict({
'a': 1,
'b': {
'c': {
'd': 'foo'
}
}
})
assert (key in dd) is expected

9
tox.ini Normal file
View File

@ -0,0 +1,9 @@
[tox]
testenv = py34,py35,py36
[testenv]
deps=
pytest
commands =
pip install -e .
py.test tests