Skip to content

Commit

Permalink
Merge pull request #6 from DustinMoriarty/feature/env-var
Browse files Browse the repository at this point in the history
Feature/env var
  • Loading branch information
DustinMoriarty committed Nov 28, 2021
2 parents c5a07e3 + 90ab2f5 commit 4ce3df8
Show file tree
Hide file tree
Showing 8 changed files with 172 additions and 8 deletions.
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,49 @@ Now that the configurable functions are annotated, we can write a configuration
}
```

This configuration file can be loaded in the runtime portion of our implementation using `get_things()` to instantiate the configured objects created by our functions.
This configuration file can be loaded in the runtime portion of our implementation using `get_things()` to instantiate the configured objects created by our functions.

### Polymorphism
It is common to want to determine the implementation at runtime. This can be accomplished by delaring the class of an argument as a tuple of multiple types.

```python
from config_injector import config, Injector

class BaseClass:...

class ImplementationA(BaseClass):...

class ImplementationB(BaseClass):...

@config()
def implementation_a():
return ImplementationA()

@config()
def implementation_b():
return ImplementationB()

@config(t0=(implementation_a, implementation_b))
def mock_thing(t0):
return {
"t0": t0
}

# Instantiate using implementation a.
mock_thing_using_a = Injector({"t0": {"type": "implementation_a"}}).instantiate(mock_thing)
# Instantiate using implementation b.
mock_thing_using_b = Injector({"t0": {"type": "implementation_b"}}).instantiate(mock_thing)
```

### Environment Variable Interpolation
Configurations can contain environment variables for any value. Variables shall be placed within braces `${VAR_NAME}` and use only letters and underscores. For example, for the following configuration, the environment variables would be interpolated.

```python
{
"db": {
"url": "${DB_URL}",
"user": "${DB_USER}",
"password": "${DB_PASSWORD}",
}
}
```
12 changes: 9 additions & 3 deletions config_injector/exc.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ class ComponentNotFound(ConfigError):
...


class KeyNotInConfig(ConfigError):
class KeyNotInConfig(ConfigError, ValueError):
...


class InvalidConfigKey(ConfigError):
class InvalidConfigKey(ConfigError, ValueError):
...


Expand All @@ -22,7 +22,7 @@ class AppMergeCollisions(ConfigError):
...


class InvalidConfigValue(ConfigError):
class InvalidConfigValue(ConfigError, ValueError):
...


Expand All @@ -32,3 +32,9 @@ class DoesNotSupportFill(ConfigError):

class FileTypeNotRecognized(ConfigError):
...


class EnvironmentVariableNotFound(ConfigError, ValueError):
def __init__(self, *args, variable_name=None):
super().__init__(*args)
self.variable_name = variable_name
11 changes: 8 additions & 3 deletions config_injector/injector.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@
from config_injector.config import SupportsFill
from config_injector.config import fill
from config_injector.exc import FileTypeNotRecognized
from config_injector.utils import EnvFiller


class Injector(MutableMapping):
def __init__(self, context: Dict = None):
self.context = {} if context is None else context
def __init__(self, context: Dict = None, fill_env=True):
self.context = {}
self.fill_env = fill_env
self._env_filler = EnvFiller()
self.load(context)

def __getitem__(self, k: Text):
v = self.context[k]
Expand All @@ -40,7 +44,8 @@ def clear(self):
self.context = {}

def load(self, context: Dict):
self.context.update(context)
_context = self._env_filler(context)
self.context.update(_context)

def load_file(self, file: Path):
if file.name.lower().endswith(".json"):
Expand Down
41 changes: 41 additions & 0 deletions config_injector/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,46 @@
import os
import re

from typing import Callable
from typing import Dict
from typing import List
from typing import SupportsFloat
from typing import SupportsInt
from typing import Text
from typing import Union

from config_injector.exc import EnvironmentVariableNotFound


def get_type(f: Callable):
return f.__name__


class EnvFiller:
def __init__(self):
"""
Object for filling environment variables in json like python objects
(e.g. dict, list, str, float, int).
"""
self.env_var_regex = re.compile("\\${[a-zA-Z_][a-zA-Z0-9_]*}")

@staticmethod
def _get_env(m: re.Match) -> Text:
k = m.group(0).replace("${", "").replace("}", "")
val = os.getenv(str(k))
if val is None:
raise EnvironmentVariableNotFound(
f"Environment variable {k} not found.", variable_name=k
)
return str(val)

def __call__(
self, o: Union[Dict, List, Text, SupportsInt, SupportsFloat]
) -> Union[Dict, List, Text, SupportsInt, SupportsFloat]:
if isinstance(o, str):
return self.env_var_regex.sub(self._get_env, o)
if hasattr(o, "items"):
return {k: self(v) for k, v in o.items()}
if hasattr(o, "__iter__"):
return [self(v) for v in o]
return o
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "config-injector"
version = "0.2.3"
version = "0.3.0"
description = "Simple dependency injection framework for python for easy and logical app configuration."
authors = ["DustinMoriarty <dustin.moriarty@protonmail.com>"]
readme = "README.md"
Expand Down
1 change: 1 addition & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def __init__(self, pets1=None, pets2=None, owner=1):
self.owner = owner


# Polymorphism.
@config(pets=(MockThing1, MockThing2))
class MockThing4(EqVarMixin):
def __init__(self, pets):
Expand Down
23 changes: 23 additions & 0 deletions tests/test_injector.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os

from collections import namedtuple
from typing import Text

Expand Down Expand Up @@ -55,3 +57,24 @@ def test_injector_inject_nested(injector):
assert injector["things"]["t0"]["arg_1"] == things[0].arg_1
assert injector["things"]["t1"]["arg_5"] == things[1].arg_5
assert injector["things"]["arg_9"] == things[2]


@pytest.fixture()
def env_var_arg_1():
return "a"


@pytest.fixture()
def context_env_var(env_var_arg_1):
os.environ["${ARG_1}"] = env_var_arg_1
return {"arg_1": env_var_arg_1, "arg_2": "b", "arg_3": "c", "arg_4": "d"}


@pytest.fixture()
def injector_env_var(context_env_var):
return Injector(context_env_var)


def test_injector_inject_env_var(injector_env_var, env_var_arg_1):
thing_0: MockThing0 = injector_env_var.instantiate(mock_thing_0)
assert thing_0.arg_1 == env_var_arg_1
43 changes: 43 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import os

import pytest

from config_injector.exc import EnvironmentVariableNotFound
from config_injector.utils import EnvFiller


@pytest.fixture()
def test_env_context():
os.environ["FOO"] = "foo"
os.environ["TOFU"] = "tofu"
os.environ["KUNG_FU"] = "kung fu"
os.environ["mu"] = "μ"


@pytest.mark.parametrize(
"object,expected",
[
("${FOO} bar", "foo bar"),
("abc def", "abc def"),
("$abc {def}", "$abc {def}"),
("${abc def}", "${abc def}"),
("${mu}", "μ"),
(
[["abc${FOO}def", "I like ${TOFU}"], {"${KUNG_FU}": "${KUNG_FU}"}],
[["abcfoodef", "I like tofu"], {"${KUNG_FU}": "kung fu"}],
),
(
{"a": 1, "b": 1.1, "c": ["${FOO}"], 10: "ten"},
{"a": 1, "b": 1.1, "c": ["foo"], 10: "ten"},
),
],
)
def test_env_filler(object, expected, test_env_context):
env_filler = EnvFiller()
assert env_filler(object) == expected


def test_env_filler_raises_env_variable_not_found():
env_filler = EnvFiller()
with pytest.raises(EnvironmentVariableNotFound):
env_filler("${NOT_A_VAR}")

0 comments on commit 4ce3df8

Please sign in to comment.