Skip to content
Merged
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
3 changes: 1 addition & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ jobs:
strategy:
matrix:
python-version:
- 3.9
- '3.10'
- 3.11
- 3.12
Expand All @@ -23,7 +22,7 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Install Dependencies
run: |
python -m pip install .[tests]
python -m pip install .[tests,yaml]
- name: Unit tests
run: |
make tests
Expand Down
31 changes: 31 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,34 @@ config object, you can specify the ENV_PREFIX attribute.
>>> os.environ['FIGENV_USER'] = 'newuser'
>>> Config.USER
'newuser'

Environment Files
-----------------

Some containerized runtimes use file mounts as a more secure access pattern for
storing secrets. Figenv supports loading overrides for the config objects from
those files. By default, it supports loading ``json`` data but if the ``yaml``
extra is installed, then yaml environment files can be used.

.. code-block:: yaml

# env.yaml
FOO: "eggs"
BAR: "spam"
BAZ: false

.. code-block:: python

>>> import figenv
>>> class Config(metaclass=figenv.MetaConfig):
... ENV_FILE="files/test.json"
... FOO = "tofu"
... BAR = "tofurkey"
... BAZ = True
...
>>> Config.FOO
'eggs'
>>> Config.BAR
'spam'
>>> Config.BAZ
False
23 changes: 19 additions & 4 deletions figenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ class Config(metaclass=MetaConfig):
except ImportError:
NoneType = type(None)

try:
import yaml
except ImportError:
yaml = None

_MISSING = type("MISSING", (object,), {"__repr__": lambda self: "<MISSING CONFIGURATION>"})()


Expand All @@ -33,7 +38,7 @@ def __init__(self, name, message=None):


def _check_special_names(name):
return name in ('name', 'keys') or name.startswith('_')
return name in ('name', 'keys', 'environ') or name.startswith('_')


def strict(f):
Expand All @@ -46,6 +51,16 @@ class MetaConfig(type):
def __init__(cls, name, bases, dict):
super().__init__(name, bases, dict)
cls.name = name
cls.environ = os.environ
if "ENV_FILE" in dict:
if os.path.exists(dict["ENV_FILE"]):
with open(dict["ENV_FILE"]) as fh_:
if yaml is not None:
cls.environ = yaml.safe_load(fh_)
else:
cls.environ = json.load(fh_)
else:
print(f"Failed to load env_file: {dict['ENV_FILE']}")
cls._dict = {}
for base in bases:
if not hasattr(base, '_dict'):
Expand Down Expand Up @@ -150,7 +165,7 @@ def __getattr__(cls, name):
prefix = cls._dict.get('ENV_PREFIX', '')
load_all = cls._dict.get('ENV_LOAD_ALL', False)

if (not load_all and name not in cls._dict) or (name not in cls._dict and prefix + name not in os.environ):
if (not load_all and name not in cls._dict) or (name not in cls._dict and prefix + name not in cls.environ):
raise AttributeError(f"type object {cls.name} has no attribute '{name}'")

value = func = cls._dict.get(name, _MISSING)
Expand All @@ -159,8 +174,8 @@ def __getattr__(cls, name):
if callable(value) and getattr(func, "_strict", False):
override_via_environment = False

if override_via_environment and prefix + name in os.environ:
value = os.environ[prefix + name]
if override_via_environment and prefix + name in cls.environ:
value = cls.environ[prefix + name]

if value is _MISSING:
# Configuration with no default and no value in the environment
Expand Down
5 changes: 5 additions & 0 deletions files/test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"FOO": "eggs",
"BAR": "spam",
"BAZ": false
}
3 changes: 3 additions & 0 deletions files/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FOO: eggs
BAR: spam
BAZ: false
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ tests =
codecov
black
unittest-xml-reporting
unittest2
docs =
sphinx
sphinx-rtd-theme
yaml =
pyyaml
39 changes: 39 additions & 0 deletions test.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import xmlrunner


import figenv
from figenv import MetaConfig, strict, MissingConfigurationException, _MISSING


Expand Down Expand Up @@ -70,6 +71,44 @@ def test_default_settings(self):
with self.assertRaises(RuntimeError):
TestConfiguration["NO_DEFAULT_SETTING"]

def test_env_file_settings(self):
"""Test that if the env file is set, settings get loaded from it."""
TestConfiguration = self._get_test_configuration(
ENV_FILE="files/test.json",
FOO='no',
BAR='yes',
BAZ=True,
)
self.assertEqual(TestConfiguration.FOO, 'eggs')
self.assertEqual(TestConfiguration.BAR, 'spam')
self.assertEqual(TestConfiguration.BAZ, False)

def test_env_file_settings_json(self):
"""Test that if the env file is set, settings get loaded from it."""
figenv.yaml = None
TestConfiguration = self._get_test_configuration(
ENV_FILE="files/test.json",
FOO='no',
BAR='yes',
BAZ=True,
)
self.assertEqual(TestConfiguration.FOO, 'eggs')
self.assertEqual(TestConfiguration.BAR, 'spam')
self.assertEqual(TestConfiguration.BAZ, False)

def test_env_file_settings_missing_file(self):
"""Test that if the env file is set, but file is missing, defaults are used"""
figenv.yaml = None
TestConfiguration = self._get_test_configuration(
ENV_FILE="files/missing.json",
FOO='no',
BAR='yes',
BAZ=True,
)
self.assertEqual(TestConfiguration.FOO, 'no')
self.assertEqual(TestConfiguration.BAR, 'yes')
self.assertEqual(TestConfiguration.BAZ, True)

def test_invalid_setter(self):
"""users should not be able to set variables using attributes"""
TestConfiguration = self._get_test_configuration(DEFAULT_SETTING='default_value', BOOL_SETTING=True)
Expand Down
Loading