diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e244c5f..d764ec3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,6 @@ jobs: strategy: matrix: python-version: - - 3.9 - '3.10' - 3.11 - 3.12 @@ -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 diff --git a/docs/usage.rst b/docs/usage.rst index f27019c..66c65f2 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -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 diff --git a/figenv.py b/figenv.py index 5c1d7e1..98e9592 100644 --- a/figenv.py +++ b/figenv.py @@ -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: ""})() @@ -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): @@ -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'): @@ -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) @@ -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 diff --git a/files/test.json b/files/test.json new file mode 100644 index 0000000..575bcdf --- /dev/null +++ b/files/test.json @@ -0,0 +1,5 @@ +{ + "FOO": "eggs", + "BAR": "spam", + "BAZ": false +} diff --git a/files/test.yaml b/files/test.yaml new file mode 100644 index 0000000..1053c54 --- /dev/null +++ b/files/test.yaml @@ -0,0 +1,3 @@ +FOO: eggs +BAR: spam +BAZ: false diff --git a/setup.cfg b/setup.cfg index d2d784d..c75d064 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,6 +33,9 @@ tests = codecov black unittest-xml-reporting + unittest2 docs = sphinx sphinx-rtd-theme +yaml = + pyyaml diff --git a/test.py b/test.py index 7f19251..1a05c9a 100644 --- a/test.py +++ b/test.py @@ -6,6 +6,7 @@ import xmlrunner +import figenv from figenv import MetaConfig, strict, MissingConfigurationException, _MISSING @@ -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)