Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Pipfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
pyyaml = "<6.1,>=5"

[dev-packages]

[requires]
python_version = "3.10"
67 changes: 67 additions & 0 deletions Pipfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 28 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# Python YAML configuration with environment variables parsing

## TL;DR
A very small library that parses a yaml configuration file and it resolves the environment variables,
A very small library that parses a yaml configuration file and it resolves the environment variables,
so that no secrets are kept in text.

### Install
Expand All @@ -16,7 +16,7 @@ pip install pyaml-env
```
### How to use:

---
---

#### Basic Usage: Environment variable parsing
This yaml file:
Expand Down Expand Up @@ -124,12 +124,12 @@ test1:
```
will raise a `ValueError` because `data1: !TEST ${ENV_TAG2}` there is no default value for `ENV_TAG2` in this line.

---
---


#### Using a different loader:

The default yaml loader is `yaml.SafeLoader`. If you need to work with serialized Python objects,
The default yaml loader is `yaml.SafeLoader`. If you need to work with serialized Python objects,
you can specify a different loader.

So given a class:
Expand Down Expand Up @@ -157,6 +157,29 @@ other_load_test = parse_config(path='path/to/config.yaml', loader=yaml.UnsafeLoa
print(other_load_test)
<__main__.OtherLoadTest object at 0x7fc38ccd5470>
```

---

#### Using PyamlEnvConstructor:

Simple way to add !ENV constructor to a pyyaml loader.

```python
from yaml import Loader
from pyaml_env import PyamlEnvConstructor

PyamlEnvConstructor.add_to_loader_class(loader_class=Loader)
# or
PyamlEnvConstructor.add_to_loader_class(
loader_class=Loader,
tag=custom_tag,
add_implicit_resolver=True,
sep=custom_sep,
default_value=custom_default_value,
raise_if_na=True
)
```

---

## Long story: Load a YAML configuration file and resolve any environment variables
Expand Down Expand Up @@ -263,7 +286,7 @@ Or even better, so that the password is not echoed in the terminal:
```python

# To run this:
# export DB_PASS=very_secret_and_complex
# export DB_PASS=very_secret_and_complex
# python use_env_variables_in_config_example.py -c /path/to/yaml
# do stuff with conf, e.g. access the database password like this: conf['database']['DB_PASS']

Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
PyYAML>=5.*, <=6.*
PyYAML>=5,<6.1
3 changes: 2 additions & 1 deletion src/pyaml_env/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .constructor import PyamlEnvConstructor
from .parse_config import parse_config
from .base_config import BaseConfig

__all__ = ['parse_config', 'BaseConfig']
__all__ = ['PyamlEnvConstructor', 'parse_config', 'BaseConfig']
2 changes: 1 addition & 1 deletion src/pyaml_env/base_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ def errors(self):
return self._errors

def validate(self):
raise NotImplementedError()
raise NotImplementedError()
86 changes: 86 additions & 0 deletions src/pyaml_env/constructor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import os
import re
import yaml


class PyamlEnvConstructor:
"""The `env constructor` for PyYAML Loaders
Call :meth:`add_to_loader_class` or :meth:`yaml.Loader.add_constructor` to
add it into loader.
In YAML files, use ``!ENV`` to resolves the environment variables::
!ENV ${DB_USER:paws}
or::
!ENV 'http://${DB_BASE_URL:straight_to_production}:${DB_PORT:12345}'
"""

DEFAULT_TAG_NAME = '!ENV'
DEFAULT_ADD_IMPLICIT_RESOLVER = False
DEFAULT_SEP = ':'
DEFAULT_VALUE = 'N/A'
DEFAULT_RAISE_IF_NA = False

@classmethod
def add_to_loader_class(cls,
loader_class=None,
tag=DEFAULT_TAG_NAME,
add_implicit_resolver=DEFAULT_ADD_IMPLICIT_RESOLVER,
**kwargs):
instance = cls(**kwargs)
if add_implicit_resolver:
yaml.add_implicit_resolver(tag, instance.pattern, None, loader_class)
yaml.add_constructor(tag, instance, loader_class)
return instance

@property
def pattern(self):
sep_pattern = r'(' + self.sep + '[^}]+)?' if self.sep else ''
return re.compile(r'.*?\$\{([^}{' + self.sep + r']+)' + sep_pattern + r'\}.*?')

def __init__(self, sep=DEFAULT_SEP, default_value=DEFAULT_VALUE, raise_if_na=DEFAULT_RAISE_IF_NA):
self.sep = sep
self.default_value = default_value
self.raise_if_na = raise_if_na

def __call__(self, loader, node):
"""
Extracts the environment variable from the yaml node's value
:param yaml.Loader loader: the yaml loader (as defined above)
:param node: the current node (key-value) in the yaml
:return: the parsed string that contains the value of the environment
variable or the default value if defined for the variable. If no value
for the variable can be found, then the value is replaced by
default_value='N/A'
"""
value = loader.construct_scalar(node)
match = self.pattern.findall(value) # to find all env variables in line
if match:
full_value = value
for g in match:
curr_default_value = self.default_value
env_var_name = g
env_var_name_with_default = g
if self.sep and isinstance(g, tuple) and len(g) > 1:
env_var_name = g[0]
env_var_name_with_default = ''.join(g)
found = False
for each in g:
if self.sep in each:
_, curr_default_value = each.split(self.sep, 1)
found = True
break
if not found and self.raise_if_na:
raise ValueError(
f'Could not find default value for {env_var_name}'
)
full_value = full_value.replace(
f'${{{env_var_name_with_default}}}',
os.environ.get(env_var_name, curr_default_value)
)
return full_value
return value
69 changes: 17 additions & 52 deletions src/pyaml_env/parse_config.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import os
import re
import yaml
from .constructor import PyamlEnvConstructor


def parse_config(
path=None,
data=None,
tag='!ENV',
default_sep=':',
default_value='N/A',
raise_if_na=False,
tag=PyamlEnvConstructor.DEFAULT_TAG_NAME,
add_implicit_resolver=PyamlEnvConstructor.DEFAULT_ADD_IMPLICIT_RESOLVER,
default_sep=PyamlEnvConstructor.DEFAULT_SEP,
default_value=PyamlEnvConstructor.DEFAULT_VALUE,
raise_if_na=PyamlEnvConstructor.DEFAULT_RAISE_IF_NA,
loader=yaml.SafeLoader,
encoding='utf-8'
):
Expand All @@ -29,6 +29,8 @@ def parse_config(
:param str data: the yaml data itself as a stream
:param str tag: the tag to look for, if None, all env variables will be
resolved.
:param str add_implicit_resolver: add implicit resolver. All env variables
will be resolved.
:param str default_sep: if any default values are set, use this field
to separate them from the enironment variable name. E.g. ':' can be
used.
Expand All @@ -44,54 +46,17 @@ def parse_config(
"""
default_sep = default_sep or ''
default_value = default_value or ''
default_sep_pattern = r'(' + default_sep + '[^}]+)?' if default_sep else ''
pattern = re.compile(
r'.*?\$\{([^}{' + default_sep + r']+)' + default_sep_pattern + r'\}.*?')
loader = loader or yaml.SafeLoader
add_implicit_resolver = True if tag is None else add_implicit_resolver

# the tag will be used to mark where to start searching for the pattern
# e.g. a_key: !ENV somestring${ENV_VAR}other_stuff_follows
loader.add_implicit_resolver(tag, pattern, None)

def constructor_env_variables(loader, node):
"""
Extracts the environment variable from the yaml node's value
:param yaml.Loader loader: the yaml loader (as defined above)
:param node: the current node (key-value) in the yaml
:return: the parsed string that contains the value of the environment
variable or the default value if defined for the variable. If no value
for the variable can be found, then the value is replaced by
default_value='N/A'
"""
value = loader.construct_scalar(node)
match = pattern.findall(value) # to find all env variables in line
if match:
full_value = value
for g in match:
curr_default_value = default_value
env_var_name = g
env_var_name_with_default = g
if default_sep and isinstance(g, tuple) and len(g) > 1:
env_var_name = g[0]
env_var_name_with_default = ''.join(g)
found = False
for each in g:
if default_sep in each:
_, curr_default_value = each.split(default_sep, 1)
found = True
break
if not found and raise_if_na:
raise ValueError(
f'Could not find default value for {env_var_name}'
)
full_value = full_value.replace(
f'${{{env_var_name_with_default}}}',
os.environ.get(env_var_name, curr_default_value)
)
return full_value
return value

loader.add_constructor(tag, constructor_env_variables)
PyamlEnvConstructor.add_to_loader_class(
loader_class=loader,
tag=tag,
add_implicit_resolver=add_implicit_resolver,
sep=default_sep,
default_value=default_value,
raise_if_na=raise_if_na
)

if path:
with open(path, encoding=encoding) as conf_data:
Expand Down
3 changes: 1 addition & 2 deletions tests/pyaml_env_tests/test_base_config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import os
import unittest
from pyaml_env import BaseConfig

Expand Down Expand Up @@ -70,4 +69,4 @@ def test_base_config_complex_structure(self):
self.assertIsInstance(base_config.a.b.c, list)
self.assertIsInstance(base_config.a.b.d, BaseConfig)
self.assertIsInstance(base_config.a.b.d.e, int)
self.assertIsInstance(base_config.a.b.d.f, str)
self.assertIsInstance(base_config.a.b.d.f, str)
2 changes: 1 addition & 1 deletion tests/pyaml_env_tests/test_parse_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -709,4 +709,4 @@ def test_parse_config_no_tag_all_resolved(self):
}
result = parse_config(data=test_data, tag=None)

self.assertDictEqual(result, expected)
self.assertDictEqual(result, expected)