Skip to content

Commit

Permalink
Merge pull request #1 from DustinMoriarty/feature/injector
Browse files Browse the repository at this point in the history
Feature/injector
  • Loading branch information
DustinMoriarty authored Nov 27, 2020
2 parents 29339ca + c82fe81 commit 41c5693
Show file tree
Hide file tree
Showing 13 changed files with 353 additions and 107 deletions.
17 changes: 17 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[flake8]
max-line-length = 88
ignore = E501, E203, W503
per-file-ignores = __init__.py:F401
exclude =
.git
__pycache__
setup.py
build
dist
releases
.venv
.tox
.mypy_cache
.pytest_cache
.vscode
.github
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,54 @@
# config-injector
A simple json configuration dependency injector framework.
Config-injector is a very simple framework which aims to do only two things: (1) define configurable functions and (2) inject configuration data into those functions at runtime.

## Installation
Install with pip.
```bash
pip install config-injector
```

## Getting Started
Annotate any callable as a configurable function using `@config`. Note that the `@config` decorator requires that you provide callable functions for each argument. These callable functions should return the expected type. The object is to break all arguments down to fundamental types: string, integer, float or dictionary.

```python
from collections import namedtuple
from typing import Text, Dict, SupportsInt
from pathlib import Path

from config_injector import config, Injector


MockThing0 = namedtuple("MockThing0", ["arg_1", "arg_2", "arg_3", "arg_4"])

@config(arg_1=str, arg_2=str, arg_3=str, arg_4=str)
def mock_thing_0(arg_1: Text, arg_2: Text, arg_3: Text, arg_4: Text):
return MockThing0(arg_1, arg_2, arg_3, arg_4)


@config(arg_5=int, arg_6=int, arg_7=int, arg_8=int)
def mock_thing_1(arg_5, arg_6, arg_7, arg_8):
return {"key_a": arg_5, "key_b": arg_6, "key_c": arg_7, "key_d": arg_8}

@config(t0=mock_thing_0, t1=mock_thing_1, arg_9=str)
def mock_things(t0: MockThing0, t1: Dict[SupportsInt], arg_9: Text):
return (t0, t1, arg_9)

def get_things(config_file=Path("config.json")):
injector = Injector()
injector.load_file(config_file)
return injector["things"].instantiate(mock_things)
```

Now that the configurable functions are annotated, we can write a configuration for them.

```json
{
"things": {
"t0": {"arg_1": "a", "arg_2": "b", "arg_3": "c", "arg_4": "d"},
"t1": {"arg_5": 1, "arg_6": 2, "arg_7": 3, "arg_8": 4},
"arg_9": "e"
}
}
```

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.
10 changes: 10 additions & 0 deletions config_injector/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
try:
from importlib.metadata import version as get_version
except ImportError:
from importlib_metadata import version as get_version

from config_injector.config import config
from config_injector.injector import Injector


__version__ = get_version(__package__)
45 changes: 28 additions & 17 deletions config_injector/config/config.py → config_injector/config.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,41 @@
from abc import ABC
from typing import (Any, Callable, Dict, List, SupportsFloat, SupportsInt,
Text, Tuple, Union)
from typing import Any
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 Tuple
from typing import Union

from config_injector.exc import DoesNotSupportBuild
from config_injector.exc import InvalidConfigValue
from config_injector.exc import KeyNotInConfig
from config_injector.exc import TypeNotDefined
from config_injector.utils import get_type

from config_injector.config.exc import (DoesNotSupportBuild,
InvalidConfigValue, KeyNotInConfig,
TypeNotDefined)
from config_injector.config.utils import get_type

JsonTypes = Union[Dict, List, bool, SupportsInt, SupportsFloat, Text]
ComponentCallable = Union[Any, Tuple[Any]]


class SupportsBuild(ABC):
def __build__(self, **kwargs: Dict):
class SupportsFill(ABC):
def __fill__(self, **kwargs: Dict):
...


def build(f: SupportsBuild, config: Dict) -> Any:
def fill(f: SupportsFill, context: Dict) -> Any:
try:
return f.__build__(**config)
return f.__fill__(**context)
except AttributeError as e:
if not hasattr(f, "__build__"):
raise DoesNotSupportBuild(f"{f} does not support build.", e)
else:
raise e


class Config(SupportsBuild):
class Config(SupportsFill):
def __init__(self, callback: Callable, **arg_callables: ComponentCallable):
"""
A configurable component containing hints for json parsing.
Expand All @@ -49,7 +58,10 @@ def __name__(self):
raise AttributeError(f"{self.callback} has no attribute {__name__}")

def get_arg_type(self, arg_name, arg):
_arg_tp = self.arg_callables[arg_name]
try:
_arg_tp = self.arg_callables[arg_name]
except KeyError as e:
raise e
try:
type_map = {get_type(x): x for x in _arg_tp}
except TypeError:
Expand All @@ -74,7 +86,7 @@ def get_arg_type(self, arg_name, arg):
arg_tp = _arg_tp
return arg_tp

def __build__(self, **kwargs: JsonTypes) -> Any:
def __fill__(self, **kwargs: JsonTypes) -> Any:
"""
Cast data from parsed json prior to calling the callback.
Expand All @@ -85,8 +97,8 @@ def __build__(self, **kwargs: JsonTypes) -> Any:
for arg_name, arg in kwargs.items():
arg_tp = self.get_arg_type(arg_name, arg)
if arg_name in self.arg_callables:
if hasattr(arg, "items") and hasattr(arg_tp, "__build__"):
arg_cast = build(arg_tp, arg)
if hasattr(arg, "items") and hasattr(arg_tp, "__fill__"):
arg_cast = fill(arg_tp, arg)
else:
arg_cast = arg_tp(arg)
else:
Expand All @@ -101,11 +113,10 @@ def config(**arg_callables: ComponentCallable) -> Callable[[], Config]:
:param key: The key to use for the configuration.
:param kwargs: The type for each argument in the function f.
:return:
:return: Wrapper
"""

def wrapper(f) -> Config:
_key = get_type(f)
component = Config(f, **arg_callables)
return component

Expand Down
2 changes: 0 additions & 2 deletions config_injector/config/__init__.py

This file was deleted.

4 changes: 4 additions & 0 deletions config_injector/config/exc.py → config_injector/exc.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ class InvalidConfigValue(ConfigError):

class DoesNotSupportBuild(ConfigError):
...


class FileTypeNotRecognized(ConfigError):
...
70 changes: 70 additions & 0 deletions config_injector/injector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import json

from collections.abc import MutableMapping
from pathlib import Path
from typing import Dict
from typing import Text

import toml
import yaml

from config_injector.config import SupportsFill
from config_injector.config import fill
from config_injector.exc import FileTypeNotRecognized


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

def __getitem__(self, k: Text):
v = self.context[k]
if hasattr(v, "items"):
return self.__class__(v)
else:
return v

def __iter__(self):
return iter(self.context)

def __setitem__(self, k, v):
self.context[k] = v

def __delitem__(self, k):
self.context.pop(k)

def __len__(self):
return len(self.context)

def clear(self):
self.context = {}

def load(self, context: Dict):
self.context.update(context)

def load_file(self, file: Path):
if file.name.lower().endswith(".json"):
self._load_json_file(file)
elif file.name.lower().endswith(".toml"):
self._load_toml_file(file)
elif file.name.lower().endswith(".yaml") or file.name.lower().endswith(".yml"):
self._load_yaml_file(file)
else:
raise FileTypeNotRecognized(
f"Unable to determine file type for {file.name}"
)

def _load_json_file(self, file: Path):
with file.open() as f:
self.load(json.load(f))

def _load_toml_file(self, file: Path):
with file.open() as f:
self.load(toml.load(f))

def _load_yaml_file(self, file: Path):
with file.open() as f:
self.load(yaml.load(f, Loader=yaml.SafeLoader))

def instantiate(self, config: SupportsFill):
return fill(config, self.context)
File renamed without changes.
Loading

0 comments on commit 41c5693

Please sign in to comment.