diff --git a/README.rst b/README.rst index 4c4fdd5..4670df9 100644 --- a/README.rst +++ b/README.rst @@ -1,11 +1,11 @@ .. image:: https://travis-ci.org/jmcs/ecological.svg?branch=master - :target: https://travis-ci.org/jmcs/ecological + :target: https://travis-ci.org/jmcs/ecological .. image:: https://api.codacy.com/project/badge/Grade/1ff45d0e1a5a40b8ad0569e3edb0539d :alt: Codacy Badge :target: https://www.codacy.com/app/jmcs/ecological?utm_source=github.com&utm_medium=referral&utm_content=jmcs/ecological&utm_campaign=badger - -.. image:: https://api.codacy.com/project/badge/Coverage/1ff45d0e1a5a40b8ad0569e3edb0539d + +.. image:: https://api.codacy.com/project/badge/Coverage/1ff45d0e1a5a40b8ad0569e3edb0539d :target: https://www.codacy.com/app/jmcs/ecological?utm_source=github.com&utm_medium=referral&utm_content=jmcs/ecological&utm_campaign=Badge_Coverage ========== @@ -33,53 +33,11 @@ And then set the environment variables ``PORT``, ``DEBUG`` and ``LOG_LEVEL``. `` class properties from the environment variables with the same (but upper cased) name. By default the values are set at the class definition type and assigned to the class itself (i.e. the class doesn't need to be -instantiated). If needed this behavior can be changed (see the next section). - -Autoloading -============= -It is possible to defer/disable autoloading (setting) of variable values by specifying the ``autoload`` option on class definition. - -On class creation (default) ---------------------------- -When no option is provided values are loaded immediately on class creation and assigned to class attributes: - -.. code-block:: python - - class Configuration(ecological.Config): - port: int - # Values already read and set at this point. - # assert Configuration.port == - -Never ------- -When this option is chosen, no autoloading happens. In order to set variable values, the ``Config.load`` method needs to be called explicitly: - -.. code-block:: python - - class Configuration(ecological.Config, autoload=ecological.Autoload.NEVER): - port: int - # Values not set at this point. - # Accessing Configuration.port would throw AttributeError. - - Configuration.load() - # Values read and set at this point. - # assert Configuration.port == - -On object instance initialization ----------------------------------- -If it is preferred to load and store attribute values on the object instance instead of the class itself, the ``Autoload.OBJECT`` strategy can be used: - -.. code-block:: python - - class Configuration(ecological.Config, autoload=ecological.Autoload.OBJECT): - port: int - # Values not set at this point. - - config = Configuration() - # Values read and set at this point on ``config``. - # assert config.port == - # Accessing ``Configuration.port`` would throw AttributeError. +instantiated). If needed this behavior can be changed (see the Autoloading section). +Tutorial +-------- +The `tutorial `_ can be used to get to know with the library's basic features interactively. Typing Support ============== @@ -108,27 +66,8 @@ You can also decide to prefix your application configuration, for example, to av In this case the ``home`` property will be fetched from the ``MYAPP_HOME`` environment property. -Fine-grained control --------------------- -You can control how the configuration properties are set by providing a ``ecological.Variable`` instance as the default -value. - -``ecological.Variable`` receives the following parameters: - -- ``variable_name`` (mandatory) - exact name of the environment variable that will be used. -- ``default`` (optional) - default value for the property if it isn't set. -- ``transform`` (optional) - function that converts the string in the environment to the value and type you - expect in your application. The default ``transform`` function will try to cast the string to the annotation - type of the property. - -Transformation function -....................... - -The transformation function receive two parameters, a string ``representation`` with the raw value, and a -``wanted_type`` with the value of the annotation (usually, but not necessarily a ``type``). - Nested Configuration --------------------- +===================== ``Ecological.Config`` also supports nested configurations, for example: .. code-block:: python @@ -142,10 +81,115 @@ Nested Configuration This way you can group related configuration properties hierarchically. -Tutorial +Advanced ======== -The `tutorial `_ includes real examples of all the available -features. + +Fine-grained Control +--------------------- +You can control some behavior of how the configuration properties are set. + +It can be achieved by providing a ``ecological.Variable`` instance as the default +value for an attribute or by specifying global options on the class level: + +.. code-block:: python + + my_source = {"KEY1": "VALUE1"} + + class Configuration(ecological.Config, transform=lambda v, wt: v, wanted_type=int, ...): + my_var1: WantedType = ecological.Variable(transform=lambda v, wt: wt(v), source=my_source, ...) + my_var2: str + # ... + +All possible options and their meaning can be found in the table below: + ++-------------------+---------------+-----------------+-------------------------------------------------+-------------------------------------------------------------------+ +| Option | Class level | Variable level | Default | Description | ++===================+===============+=================+=================================================+===================================================================+ +| ``prefix`` | yes | no | ``None`` | A prefix that is uppercased and prepended when a variable name | +| | | | | is derived from an attribute name. | ++-------------------+---------------+-----------------+-------------------------------------------------+-------------------------------------------------------------------+ +| ``variable_name`` | yes | yes | Derived from attribute name and prefixed | When specified on the variable level it states | +| | | | with ``prefix`` if specified; uppercased. | the exact name of the source variable that will be used. | +| | | | | | +| | | | | When specified on the class level it is treated as a function | +| | | | | that returns a variable name from the attribute name with | +| | | | | the following signature: | +| | | | | | +| | | | | ``def func(attribute_name: str, prefix: Optional[str] = None)`` | ++-------------------+---------------+-----------------+-------------------------------------------------+-------------------------------------------------------------------+ +| ``default`` | no | yes | (no default) | Default value for the property if it isn't set. | ++-------------------+---------------+-----------------+-------------------------------------------------+-------------------------------------------------------------------+ +| ``transform`` | yes | yes | A source value is casted to the ``wanted_type`` | A function that converts a value from the ``source`` to the value | +| | | | In case of non-scalar types (+ scalar ``bool``) | and ``wanted_type`` you expect with the following signature: | +| | | | the value is Python-parsed first. | | +| | | | | ``def func(source_value: str, wanted_type: Union[Type, str])`` | ++-------------------+---------------+-----------------+-------------------------------------------------+-------------------------------------------------------------------+ +| ``source`` | yes | yes | ``os.environ`` | Dictionary that the value will be loaded from. | ++-------------------+---------------+-----------------+-------------------------------------------------+-------------------------------------------------------------------+ +| ``wanted_type`` | yes | yes | ``str`` | Desired Python type of the attribute's value. | +| | | | | | +| | | | | On the variable level it is specified via a type annotation on | +| | | | | the attribute: ``my_var_1: my_wanted_type``. | +| | | | | | +| | | | | However it can be also specified on the class level, then it acts | +| | | | | as a default when the annotation is not provided: | +| | | | | | +| | | | | ``class MyConfig(ecological.Config, wanted_type=int, ...)`` | ++-------------------+---------------+-----------------+-------------------------------------------------+-------------------------------------------------------------------+ + +The following rules apply when options are resolved: + +- when options are specified on both levels (variable and class), + the variable ones take precedence over class ones, +- when some options are missing on the variable level, their default values + are taken from the class level, +- it is not necessary to assign an ``ecological.Variable`` instance to + change the behavior; it can still be changed on the class level (globally). + +Autoloading +------------ +It is possible to defer/disable autoloading (setting) of variable values by specifying the ``autoload`` option on class definition. + +On class creation (default) +~~~~~~~~~~~~~~~~~~~~~~~~~~~ +When no option is provided values are loaded immediately on class creation and assigned to class attributes: + +.. code-block:: python + + class Configuration(ecological.Config): + port: int + # Values already read and set at this point. + # assert Configuration.port == + +Never +~~~~~ +When this option is chosen, no autoloading happens. In order to set variable values, the ``Config.load`` method needs to be called explicitly: + +.. code-block:: python + + class Configuration(ecological.Config, autoload=ecological.Autoload.NEVER): + port: int + # Values not set at this point. + # Accessing Configuration.port would throw AttributeError. + + Configuration.load() + # Values read and set at this point. + # assert Configuration.port == + +On object instance initialization +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +If it is preferred to load and store attribute values on the object instance instead of the class itself, the ``Autoload.OBJECT`` strategy can be used: + +.. code-block:: python + + class Configuration(ecological.Config, autoload=ecological.Autoload.OBJECT): + port: int + # Values not set at this point. + + config = Configuration() + # Values read and set at this point on ``config``. + # assert config.port == + # Accessing ``Configuration.port`` would throw AttributeError. Caveats and Known Limitations ============================= diff --git a/ecological/config.py b/ecological/config.py index 3290055..1cdb4bd 100644 --- a/ecological/config.py +++ b/ecological/config.py @@ -1,158 +1,21 @@ -import ast -import collections +""" +The heart of the library. +See ``README.rst`` and ``Configuration`` class for more details. +""" import dataclasses import enum import os import warnings -from typing import (Any, AnyStr, ByteString, Callable, Dict, FrozenSet, List, - Optional, Set, Tuple, Type, TypeVar, Union, get_type_hints) +from typing import Any, Callable, Dict, NewType, Optional, Type, Union, get_type_hints -try: - from typing import GenericMeta - - PEP560 = False -except ImportError: - GenericMeta = None - PEP560 = True - - -_NOT_IMPORTED = object() - -try: # Types added in Python 3.6.1 - from typing import Counter, Deque -except ImportError: - Deque = Counter = _NOT_IMPORTED +# Aliased in order to avoid a conflict with the _Options.transform attribute. +from . import transform as transform_module _NO_DEFAULT = object() -TYPES_THAT_NEED_TO_BE_PARSED = [bool, list, set, tuple, dict] -TYPING_TO_REGULAR_TYPE = { - AnyStr: str, - ByteString: bytes, - Counter: collections.Counter, - Deque: collections.deque, - Dict: dict, - FrozenSet: frozenset, - List: list, - Set: set, - Tuple: tuple, -} - -WantedType = TypeVar("WantedType") - - -def _cast_typing_old(wanted_type: type) -> type: - """ - Casts typing types in Python < 3.7 - """ - wanted_type = TYPING_TO_REGULAR_TYPE.get(wanted_type, wanted_type) - if isinstance(wanted_type, GenericMeta): - # Fallback to try to map complex typing types to real types - for base in wanted_type.__bases__: - # if not isinstance(base, Generic): - # # If it's not a Generic class then it can be a real type - # wanted_type = base - # break - if base in TYPING_TO_REGULAR_TYPE: - # The mapped type in bases is most likely the base type for complex types - # (for example List[int]) - wanted_type = TYPING_TO_REGULAR_TYPE[base] - break - return wanted_type - - -def _cast_typing_pep560(wanted_type: type) -> type: - """ - Casts typing types in Python >= 3.7 - See https://www.python.org/dev/peps/pep-0560/ - """ - # If it's in the dict in Python >= 3.7 we know how to handle it - if wanted_type in TYPING_TO_REGULAR_TYPE: - return TYPING_TO_REGULAR_TYPE.get(wanted_type, wanted_type) - - try: - return wanted_type.__origin__ - except AttributeError: # This means it's (probably) not a typing type - return wanted_type - - -def cast(representation: str, wanted_type: type): - """ - Casts the string ``representation`` to the ``wanted type``. - This function also supports some ``typing`` types by mapping them to 'real' types. - - Some types, like ``bool`` and ``list``, need to be parsed with ast. - """ - # The only distinguishing feature of NewType (both before and after PEP560) - # is its __supertype__ field, which it is the only "typing" member to have. - # Since newtypes can be nested, we process __supertype__ as long as available. - while hasattr(wanted_type, "__supertype__"): - wanted_type = wanted_type.__supertype__ - - # If it's another typing type replace it with the real type - if PEP560: # python >= 3.7 - wanted_type = _cast_typing_pep560(wanted_type) - else: - wanted_type = _cast_typing_old(wanted_type) - - if wanted_type in TYPES_THAT_NEED_TO_BE_PARSED: - value = ( - ast.literal_eval(representation) - if isinstance(representation, str) - else representation - ) - return wanted_type(value) - else: - return wanted_type(representation) - - -class Variable: - """ - Class to handle specific properties - """ - - def __init__( - self, - variable_name: str, - default=_NO_DEFAULT, - *, - transform: Callable[[str, type], Any] = cast, - ): - """ - :param variable_name: Environment variable to get - :param default: Default value. - :param transform: function to convert the env string to the wanted type - """ - self.name = variable_name - self.default = default - self.transform = transform - - def get(self, wanted_type: WantedType) -> Union[WantedType, Any]: - """ - Gets ``self.variable_name`` from the environment and tries to cast it to ``wanted_type``. - - If ``self.default`` is ``_NO_DEFAULT`` and the env variable is not set this will raise an - ``AttributeError``, if the ``self.default`` is set to something else, its value will be - returned. - - If casting fails, this function will raise a ``ValueError``. - - :param wanted_type: type to return - :return: value as wanted_type - """ - try: - raw_value = os.environ[self.name] - except KeyError: - if self.default is _NO_DEFAULT: - raise AttributeError(f"Configuration error: '{self.name}' is not set.") - else: - return self.default - - try: - value = self.transform(raw_value, wanted_type) - except (ValueError, SyntaxError) as e: - raise ValueError(f"Invalid configuration for '{self.name}': {e}.") - - return value +VariableName = NewType("VariableName", Union[str, bytes]) +VariableValue = NewType("VariableValue", Union[str, bytes]) +Source = NewType("Source", Dict[VariableName, VariableValue]) +TransformCallable = NewType("TransformCallable", Callable[[VariableValue, Type], Any]) class Autoload(enum.Enum): @@ -162,7 +25,7 @@ class Autoload(enum.Enum): - ``Autoload.CLASS`` - load variable values to class on its subclass creation, - ``Autoload.OBJECT`` - load variable values to object instance on its initialization, - - ``Autoload.NEVER`` - does not perform any autoloading; ``Config.load`` method needs to called explicitly. + - ``Autoload.NEVER`` - does not perform any autoloading; ``Config.load`` method needs to be called explicitly. """ CLASS = "CLASS" @@ -170,25 +33,47 @@ class Autoload(enum.Enum): NEVER = "NEVER" +def _generate_environ_name( + attr_name: str, prefix: Optional[str] = None +) -> VariableName: + """ + Outputs an environment variable name based on the ``Configuration``'s + subclass attribute name and the optional prefix. + + >>> _generate_environ_name("attr_name", prefix="prefixed") + "PREFIXED_ATTR_NAME" + """ + variable_name = "" + if prefix: + variable_name += f"{prefix}_" + variable_name += attr_name + + return variable_name.upper() + + @dataclasses.dataclass class _Options: """ - Acts as a container for metaclass keyword arguments provided during - ``Config`` class creation. + Acts as the container for metaclass keyword arguments provided during + ``Config`` class creation. """ prefix: Optional[str] = None autoload: Autoload = Autoload.CLASS + source: Source = os.environ + transform: TransformCallable = transform_module.cast + wanted_type: Type = str + variable_name: Callable[[str, Optional[str]], VariableName] = _generate_environ_name @classmethod - def from_metaclass_kwargs(cls, metaclass_kwargs: Dict) -> "Options": + def from_dict(cls, options_dict: Dict) -> "_Options": """ Produces ``_Options`` instance from given dictionary. - Items are deleted from ``metaclass_kwargs`` as a side-effect. + Items are deleted from ``options_dict`` as a side-effect. """ options_kwargs = {} for field in dataclasses.fields(cls): - value = metaclass_kwargs.pop(field.name, None) + value = options_dict.pop(field.name, None) if value is None: continue @@ -198,7 +83,61 @@ def from_metaclass_kwargs(cls, metaclass_kwargs: Dict) -> "Options": return cls(**options_kwargs) except TypeError as e: raise ValueError( - f"Invalid options for Config class: {metaclass_kwargs}." + f"Invalid options for Config class: {options_dict}." + ) from e + + +@dataclasses.dataclass +class Variable: + """ + Represents a single variable from the configuration source + and user preferences how to process it. + """ + + variable_name: Optional[VariableName] = None + default: Any = _NO_DEFAULT + transform: Optional[TransformCallable] = None + source: Optional[Source] = None + wanted_type: Type = dataclasses.field(init=False) + + def set_defaults( + self, + *, + variable_name: VariableName, + transform: TransformCallable, + source: Source, + wanted_type: Type, + ): + """ + Sets missing properties of the instance of `Variable`` in order to + be able to fetch its value with the ``Variable.get`` method. + """ + self.variable_name = self.variable_name or variable_name + self.transform = self.transform or transform + self.source = self.source or source + self.wanted_type = wanted_type + + def get(self) -> VariableValue: + """ + Fetches a value of variable from the ``self.source`` and invoke the + ``self.transform`` operation on it. Falls back to ``self.default`` + if the value is not found. + """ + try: + raw_value = self.source[self.variable_name] + except KeyError: + if self.default is _NO_DEFAULT: + raise AttributeError( + f"Configuration error: {self.variable_name!r} is not set." + ) from None + else: + return self.default + + try: + return self.transform(raw_value, self.wanted_type) + except (ValueError, SyntaxError) as e: + raise ValueError( + f"Invalid configuration for {self.variable_name!r}." ) from e @@ -219,7 +158,7 @@ class Configuration(ecological.Config): It is possible to defer the calculation of attribute values by specifying the ``autoload`` keyword argument on your class definition. For possible strategies see the ``Autoload`` class definition. - + Caveats and Known Limitations ============================= @@ -234,7 +173,7 @@ class Configuration(ecological.Config): _options: _Options def __init_subclass__(cls, **kwargs): - cls._options = _Options.from_metaclass_kwargs(kwargs) + cls._options = _Options.from_dict(kwargs) super().__init_subclass__(**kwargs) if cls._options.autoload is Autoload.CLASS: cls.load(cls) @@ -248,64 +187,47 @@ def __init__(self, *args, **kwargs): @classmethod def load(cls: "Config", target_obj: Optional[object] = None): """ - The class' ``attribute_dict`` includes attributes with a default value and some special - keys like ``__annotations__`` which includes annotations of all attributes, including the - ones that don't have a value. - - To simplify the class building process the annotated attributes that don't have a value - in the ``attribute_dict`` are injected with a ``_NO_DEFAULT`` sentinel object. - - After this all the public attributes of the class are iterated over and one - of three things is performed: - - - If the attribute value is an instance of ``Config`` it is kept as is to allow nested - configuration. - - If the attribute value is of type ``Variable``, ``Config`` will call its ``get`` - method with the attribute's annotation type as the only parameter - - Otherwise, ``Config`` will create a ``Variable`` instance, with - "{prefix}_{attribute_name}" as the environment variable name and the attribute value - (the default value or ``_NO_DEFAULT``) and do the same process as in the previous point. + Fetches and converts values of variables declared as attributes on ``cls`` according + to their specification and finally assigns them to the corresponding attributes + on ``target_obj`` (which by default is ``cls`` itself). """ target_obj = target_obj or cls - annotations: Dict[str, type] = get_type_hints(cls) - attribute_dict = vars(cls).copy() - prefix = cls._options.prefix - - # Add attributes without defaults to the the attribute dict - attribute_dict.update( - { - attribute_name: _NO_DEFAULT - for attribute_name in annotations.keys() - if attribute_name not in attribute_dict - } - ) - - for attribute_name, default_value in attribute_dict.items(): - if attribute_name.startswith("_"): - # private attributes are not changed - continue - if isinstance(default_value, Variable): - attribute = default_value - elif isinstance(default_value, Config): - # passthrough for nested configs - setattr(target_obj, attribute_name, default_value) + cls_dict = vars(cls).copy() + attr_types: Dict[str, type] = get_type_hints(cls) + # There is no single place that has all class attributes regardless of + # having default value or not. Thus the keys of cls.__annotations__ and + # cls.__dict__ are merged providing a complete list. + attr_names = set(cls_dict).union(attr_types.keys()) + + for attr_name in attr_names: + # Omit private and nested configuration attributes + # (Attribute value can be the instance of Config itself). + if attr_name.startswith("_") or isinstance(attr_name, cls): continue + attr_value = cls_dict.get(attr_name, _NO_DEFAULT) + attr_type = attr_types.get(attr_name, cls._options.wanted_type) + + if isinstance(attr_value, Variable): + variable = attr_value else: - if prefix: - env_variable_name = f"{prefix}_{attribute_name}".upper() - else: - env_variable_name = attribute_name.upper() - attribute = Variable(env_variable_name, default_value) + variable = Variable(default=attr_value) + variable.set_defaults( + variable_name=cls._options.variable_name( + attr_name, prefix=cls._options.prefix + ), + transform=cls._options.transform, + source=cls._options.source, + wanted_type=attr_type, + ) - attribute_type = annotations.get( - attribute_name, str - ) # by default attributes are strings - value = attribute.get(attribute_type) - setattr(target_obj, attribute_name, value) + setattr(target_obj, attr_name, variable.get()) -# DEPRECATED: For backward compatibility purposes only class AutoConfig(Config, autoload=Autoload.NEVER): + """ + DEPRECATED: For backward compatibility purposes only; please use ``ecological.Config`` instead. + """ + def __init_subclass__(cls, prefix: Optional[str] = None, **kwargs): warnings.warn( "ecological.AutoConfig is deprecated, please use ecological.Config instead.", diff --git a/ecological/transform.py b/ecological/transform.py new file mode 100644 index 0000000..f5e74d4 --- /dev/null +++ b/ecological/transform.py @@ -0,0 +1,101 @@ +""" +Provides ``cast`` function (default transform function for variables of `config.Config`) +mitigating a number of `typing` module quirks that happen across Python versions. +""" +import ast +import collections +from typing import AnyStr, ByteString, Dict, FrozenSet, List, Set, Tuple + +try: + from typing import GenericMeta + + PEP560 = False +except ImportError: + GenericMeta = None + PEP560 = True + + +_NOT_IMPORTED = object() + +try: # Types added in Python 3.6.1 + from typing import Counter, Deque +except ImportError: + Deque = Counter = _NOT_IMPORTED + +TYPES_THAT_NEED_TO_BE_PARSED = [bool, list, set, tuple, dict] +TYPING_TO_REGULAR_TYPE = { + AnyStr: str, + ByteString: bytes, + Counter: collections.Counter, + Deque: collections.deque, + Dict: dict, + FrozenSet: frozenset, + List: list, + Set: set, + Tuple: tuple, +} + + +def _cast_typing_old(wanted_type: type) -> type: + """ + Casts typing types in Python < 3.7 + """ + wanted_type = TYPING_TO_REGULAR_TYPE.get(wanted_type, wanted_type) + if isinstance(wanted_type, GenericMeta): + # Fallback to try to map complex typing types to real types + for base in wanted_type.__bases__: + # if not isinstance(base, Generic): + # # If it's not a Generic class then it can be a real type + # wanted_type = base + # break + if base in TYPING_TO_REGULAR_TYPE: + # The mapped type in bases is most likely the base type for complex types + # (for example List[int]) + wanted_type = TYPING_TO_REGULAR_TYPE[base] + break + return wanted_type + + +def _cast_typing_pep560(wanted_type: type) -> type: + """ + Casts typing types in Python >= 3.7 + See https://www.python.org/dev/peps/pep-0560/ + """ + # If it's in the dict in Python >= 3.7 we know how to handle it + if wanted_type in TYPING_TO_REGULAR_TYPE: + return TYPING_TO_REGULAR_TYPE.get(wanted_type, wanted_type) + + try: + return wanted_type.__origin__ + except AttributeError: # This means it's (probably) not a typing type + return wanted_type + + +def cast(representation: str, wanted_type: type): + """ + Casts the string ``representation`` to the ``wanted type``. + This function also supports some ``typing`` types by mapping them to 'real' types. + + Some types, like ``bool`` and ``list``, need to be parsed with ast. + """ + # The only distinguishing feature of NewType (both before and after PEP560) + # is its __supertype__ field, which it is the only "typing" member to have. + # Since newtypes can be nested, we process __supertype__ as long as available. + while hasattr(wanted_type, "__supertype__"): + wanted_type = wanted_type.__supertype__ + + # If it's another typing type replace it with the real type + if PEP560: # python >= 3.7 + wanted_type = _cast_typing_pep560(wanted_type) + else: + wanted_type = _cast_typing_old(wanted_type) + + if wanted_type in TYPES_THAT_NEED_TO_BE_PARSED: + value = ( + ast.literal_eval(representation) + if isinstance(representation, str) + else representation + ) + return wanted_type(value) + else: + return wanted_type(representation) diff --git a/poetry.lock b/poetry.lock index 1fa20b5..0e75eca 100644 --- a/poetry.lock +++ b/poetry.lock @@ -36,6 +36,14 @@ attrs = ">=17.4.0" click = ">=6.5" toml = ">=0.9.4" +[[package]] +category = "dev" +description = "Universal encoding detector for Python 2 and 3" +name = "chardet" +optional = false +python-versions = "*" +version = "3.0.4" + [[package]] category = "dev" description = "Composable command line interface toolkit" @@ -69,6 +77,29 @@ optional = false python-versions = "*" version = "0.6" +[[package]] +category = "dev" +description = "Style checker for Sphinx (or other) RST documentation" +name = "doc8" +optional = false +python-versions = "*" +version = "0.8.0" + +[package.dependencies] +chardet = "*" +docutils = "*" +restructuredtext-lint = ">=0.7" +six = "*" +stevedore = "*" + +[[package]] +category = "dev" +description = "Docutils -- Python Documentation Utilities" +name = "docutils" +optional = false +python-versions = "*" +version = "0.14" + [[package]] category = "dev" description = "Discover and load entry points from installed packages." @@ -139,6 +170,14 @@ version = "19.0" pyparsing = ">=2.0.2" six = "*" +[[package]] +category = "dev" +description = "Python Build Reasonableness" +name = "pbr" +optional = false +python-versions = "*" +version = "5.2.1" + [[package]] category = "dev" description = "plugin and hook calling mechanisms for python" @@ -174,6 +213,14 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "2.1.1" +[[package]] +category = "dev" +description = "Pygments is a syntax highlighting package written in Python." +name = "pygments" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "2.4.2" + [[package]] category = "dev" description = "Python parsing module" @@ -217,6 +264,17 @@ version = "2.7.1" coverage = ">=4.4" pytest = ">=3.6" +[[package]] +category = "dev" +description = "reStructuredText linter" +name = "restructuredtext-lint" +optional = false +python-versions = "*" +version = "1.3.0" + +[package.dependencies] +docutils = ">=0.11,<1.0" + [[package]] category = "dev" description = "a python refactoring library..." @@ -233,6 +291,18 @@ optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*" version = "1.12.0" +[[package]] +category = "dev" +description = "Manage dynamic plugins for Python applications" +name = "stevedore" +optional = false +python-versions = "*" +version = "1.30.1" + +[package.dependencies] +pbr = ">=2.0.0,<2.1.0 || >2.1.0" +six = ">=1.10.0" + [[package]] category = "dev" description = "Python Library for Tom's Obvious, Minimal Language" @@ -283,7 +353,7 @@ python-versions = ">=2.7" version = "0.5.1" [metadata] -content-hash = "e7c8c6f2ef25b1b836c079e8d1edc7759c8e9c699764163fd81e0369b1a3a235" +content-hash = "a459a098ec36db016ed6f1983da3aa26cfb0f48b39a943d8a3640dcab1f8efe6" python-versions = "^3.6" [metadata.hashes] @@ -291,10 +361,13 @@ appdirs = ["9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", " atomicwrites = ["03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", "75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"] attrs = ["69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", "f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399"] black = ["817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739", "e030a9a28f542debc08acceb273f228ac422798e5215ba2a791a6ddeaaca22a5"] +chardet = ["84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"] click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"] colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"] coverage = ["007eeef7e23f9473622f7d94a3e029a45d55a92a1f083f0f3512f5ab9a669b05", "0388c12539372bb92d6dde68b4627f0300d948965bbb7fc104924d715fdc0965", "079248312838c4c8f3494934ab7382a42d42d5f365f0cf7516f938dbb3f53f3f", "17307429935f96c986a1b1674f78079528833410750321d22b5fb35d1883828e", "1afccd7e27cac1b9617be8c769f6d8a6d363699c9b86820f40c74cfb3328921c", "2ad357d12971e77360034c1596011a03f50c0f9e1ecd12e081342b8d1aee2236", "2b4d7f03a8a6632598cbc5df15bbca9f778c43db7cf1a838f4fa2c8599a8691a", "2e1a5c6adebb93c3b175103c2f855eda957283c10cf937d791d81bef8872d6ca", "309d91bd7a35063ec7a0e4d75645488bfab3f0b66373e7722f23da7f5b0f34cc", "358d635b1fc22a425444d52f26287ae5aea9e96e254ff3c59c407426f44574f4", "3f4d0b3403d3e110d2588c275540649b1841725f5a11a7162620224155d00ba2", "43a155eb76025c61fc20c3d03b89ca28efa6f5be572ab6110b2fb68eda96bfea", "493082f104b5ca920e97a485913de254cbe351900deed72d4264571c73464cd0", "4c4f368ffe1c2e7602359c2c50233269f3abe1c48ca6b288dcd0fb1d1c679733", "5ff16548492e8a12e65ff3d55857ccd818584ed587a6c2898a9ebbe09a880674", "66f393e10dd866be267deb3feca39babba08ae13763e0fc7a1063cbe1f8e49f6", "700d7579995044dc724847560b78ac786f0ca292867447afda7727a6fbaa082e", "81912cfe276e0069dca99e1e4e6be7b06b5fc8342641c6b472cb2fed7de7ae18", "82cbd3317320aa63c65555aa4894bf33a13fb3a77f079059eb5935eea415938d", "845fddf89dca1e94abe168760a38271abfc2e31863fbb4ada7f9a99337d7c3dc", "87d942863fe74b1c3be83a045996addf1639218c2cb89c5da18c06c0fe3917ea", "9721f1b7275d3112dc7ccf63f0553c769f09b5c25a26ee45872c7f5c09edf6c1", "a4497faa4f1c0fc365ba05eaecfb6b5d24e3c8c72e95938f9524e29dadb15e76", "a7cfaebd8f24c2b537fa6a271229b051cdac9c1734bb6f939ccfc7c055689baa", "ab3508df9a92c1d3362343d235420d08e2662969b83134f8a97dc1451cbe5e84", "b0059630ca5c6b297690a6bf57bf2fdac1395c24b7935fd73ee64190276b743b", "b6cebae1502ce5b87d7c6f532fa90ab345cfbda62b95aeea4e431e164d498a3d", "bd4800e32b4c8d99c3a2c943f1ac430cbf80658d884123d19639bcde90dad44a", "cdd92dd9471e624cd1d8c1a2703d25f114b59b736b0f1f659a98414e535ffb3d", "d00e29b78ff610d300b2c37049a41234d48ea4f2d2581759ebcf67caaf731c31", "d1ee76f560c3c3e8faada866a07a32485445e16ed2206ac8378bd90dadffb9f0", "dd707a21332615108b736ef0b8513d3edaf12d2a7d5fc26cd04a169a8ae9b526", "e3ba9b14607c23623cf38f90b23f5bed4a3be87cbfa96e2e9f4eabb975d1e98b", "e9a0e1caed2a52f15c96507ab78a48f346c05681a49c5b003172f8073da6aa6b", "eea9135432428d3ca7ee9be86af27cb8e56243f73764a9b6c3e0bda1394916be", "f29841e865590af72c4b90d7b5b8e93fd560f5dea436c1d5ee8053788f9285de", "f3a5c6d054c531536a83521c00e5d4004f1e126e2e2556ce399bef4180fbe540", "f87f522bde5540d8a4b11df80058281ac38c44b13ce29ced1e294963dd51a8f8", "f8c55dd0f56d3d618dfacf129e010cbe5d5f94b6951c1b2f13ab1a2f79c284da", "f98b461cb59f117887aa634a66022c0bd394278245ed51189f63a036516e32de"] dataclasses = ["454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f", "6988bd2b895eef432d562370bb707d540f32f7360ab13da45340101bc2307d84"] +doc8 = ["2df89f9c1a5abfb98ab55d0175fed633cae0cf45025b8b1e0ee5ea772be28543", "d12f08aa77a4a65eb28752f4bc78f41f611f9412c4155e2b03f1f5d4a45efe04"] +docutils = ["02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", "51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274", "7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"] entrypoints = ["589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"] filelock = ["18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59", "929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"] flake8 = ["859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661", "a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8"] @@ -302,15 +375,19 @@ importlib-metadata = ["a9f185022cfa69e9ca5f7eabfd5a58b689894cb78a11e3c8c89398a8c mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"] more-itertools = ["2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7", "c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a"] packaging = ["0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af", "9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3"] +pbr = ["0ce920b865091450bbcd452b35cf6d6eb8a6d9ce13ad2210d6e77557f85cf32b", "93d2dc6ee0c9af4dbc70bc1251d0e545a9910ca8863774761f92716dece400b6"] pluggy = ["0825a152ac059776623854c1543d65a4ad408eb3d33ee114dff91e57ec6ae6fc", "b9817417e95936bf75d85d3f8767f7df6cdde751fc40aed3bb3074cbcb77757c"] py = ["64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", "dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"] pycodestyle = ["95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"] pyflakes = ["17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", "d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"] +pygments = ["71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", "881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"] pyparsing = ["1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a", "9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03"] pytest = ["6032845e68a17a96e8da3088037f899b56357769a724122056265ca2ea1890ee", "bea27a646a3d74cbbcf8d3d4a06b2dfc336baf3dc2cc85cf70ad0157e73e8322"] pytest-cov = ["2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6", "e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a"] +restructuredtext-lint = ["97b3da356d5b3a8514d8f1f9098febd8b41463bed6a1d9f126cf0a048b6fd908"] rope = ["6b728fdc3e98a83446c27a91fc5d56808a004f8beab7a31ab1d7224cecc7d969", "c5c5a6a87f7b1a2095fb311135e2a3d1f194f5ecb96900fdd0a9100881f48aaf", "f0dcf719b63200d492b85535ebe5ea9b29e0d0b8aebeb87fe03fc1a65924fdaf"] six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] +stevedore = ["7be098ff53d87f23d798a7ce7ae5c31f094f3deb92ba18059b1aeb1ca9fec0a0", "7d1ce610a87d26f53c087da61f06f9b7f7e552efad2a7f6d2322632b5f932ea2"] toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"] tox = ["f5c8e446b51edd2ea97df31d4ded8c8b72e7d6c619519da6bb6084b9dd5770f9", "f87fd33892a2df0950e5e034def9468988b8d008c7e9416be665fcc0dd45b14f"] virtualenv = ["99acaf1e35c7ccf9763db9ba2accbca2f4254d61d1912c5ee364f9cc4a8942a0", "fe51cdbf04e5d8152af06c075404745a7419de27495a83f0d72518ad50be3ce8"] diff --git a/pyproject.toml b/pyproject.toml index f33b84b..d988ddd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,8 @@ pytest-cov = "^2.7" black = {version = "^18.3-alpha.0",allows-prereleases = true} rope = "^0.14.0" flake8 = "^3.7" +doc8 = "^0.8.0" +pygments = "^2.4" [build-system] requires = ["poetry>=0.12"] diff --git a/tests/test_config.py b/tests/test_config.py index 7baaf6d..08b4b19 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ #!/usr/bin/python3 +import os import typing import pytest @@ -203,3 +204,70 @@ def test_config_autoload_is_ignored_on_autoconfig(): class Configuration(ecological.AutoConfig, autoload=ecological.Autoload.NEVER): my_var1: str + + +def test_variable_is_loaded_from_source(monkeypatch, base_class): + monkeypatch.setitem(os.environb, b"A_BYTES", b"a-bytes-value") + + class Configuration(base_class): + a_bytes: bytes = ecological.Variable(b"A_BYTES", source=os.environb) + + assert Configuration.a_bytes == b"a-bytes-value" + + +def test_global_transform_option_is_used_as_default(monkeypatch): + monkeypatch.setenv("IMPLICIT", "a") + monkeypatch.setenv("VAR_WITH_TRANSFORM", "b") + monkeypatch.setenv("VAR_WITHOUT_TRANSFORM", "c") + + class Configuration(ecological.Config, transform=lambda *args: "GLOBAL_TRANSFORM"): + implicit: str + var_without_transform = ecological.Variable("VAR_WITHOUT_TRANSFORM") + var_with_transform: int = ecological.Variable( + "VAR_WITH_TRANSFORM", transform=lambda *args: "VAR_TRANSFORM" + ) + + assert Configuration.implicit == "GLOBAL_TRANSFORM" + assert Configuration.var_with_transform == "VAR_TRANSFORM" + assert Configuration.var_without_transform == "GLOBAL_TRANSFORM" + + +def test_global_source_option_is_used_as_default(monkeypatch): + my_dict = {"IMPLICIT": "a", "VAR_WITHOUT_SOURCE": "b"} + monkeypatch.setenv("VAR_WITH_SOURCE", "c") + + class Configuration(ecological.Config, source=my_dict): + implicit: str + var_without_source = ecological.Variable("VAR_WITHOUT_SOURCE") + var_with_source = ecological.Variable("VAR_WITH_SOURCE", source=os.environ) + + assert Configuration.implicit == "a" + assert Configuration.var_without_source == "b" + assert Configuration.var_with_source == "c" + + +def test_global_wanted_type_option_is_used_as_default(monkeypatch): + monkeypatch.setenv("IMPLICIT_1", "1") + monkeypatch.setenv("IMPLICIT_WITH_TYPE_2", "2") + + class Configuration(ecological.Config, wanted_type=int): + implicit_1 = 99 + implicit_with_type_2: str + + assert Configuration.implicit_1 == 1 + assert Configuration.implicit_with_type_2 == "2" + + +def test_variable_name_is_calculation_is_used_as_default(monkeypatch): + monkeypatch.setenv("IMPLICIT_THIS_IS_CRAZY", "1") + monkeypatch.setenv("MY_ARBITRARY_NAME", "2") + + def my_variable_name(attr_name, prefix=None): + return (attr_name + "_THIS_IS_CRAZY").upper() + + class Configuration(ecological.Config, variable_name=my_variable_name): + implicit: str + var_with_name = ecological.Variable("MY_ARBITRARY_NAME") + + assert Configuration.implicit == "1" + assert Configuration.var_with_name == "2" diff --git a/tutorial.ipynb b/tutorial.ipynb index 80f5e55..7377e4a 100644 --- a/tutorial.ipynb +++ b/tutorial.ipynb @@ -44,7 +44,7 @@ "source": [ "import ecological\n", "\n", - "class Configuration(ecological.AutoConfig):\n", + "class Configuration(ecological.Config):\n", " integer_list: list\n", " integer: int\n", " dictionary: dict\n", @@ -201,7 +201,7 @@ "source": [ "from typing import List, Dict\n", "\n", - "class ConfigurationTyping(ecological.AutoConfig):\n", + "class ConfigurationTyping(ecological.Config):\n", " integer_list: List\n", " dictionary: Dict" ] @@ -275,7 +275,7 @@ "os.environ[\"CONFIG_HOME\"] = \"/app/home\"\n", "os.environ[\"CONFIG_VALUE\"] = \"Prefixed\"\n", "\n", - "class ConfigurationPrefix(ecological.AutoConfig, prefix=\"config\"):\n", + "class ConfigurationPrefix(ecological.Config, prefix=\"config\"):\n", " home: str\n", " value: str" ] @@ -331,7 +331,7 @@ "\n", "`ecological.Variable` receives the following parameters:\n", "\n", - "`variable_name` (mandatory) - exact name of the environment variable that will be used.\n", + "`variable_name` (optional) - exact name of the environment variable that will be used.\n", "`default` (optional) - default value for the property if it isn't set.\n", "`transform` (optional) - function that converts the string in the environment to the value and type you expect in your application. The default `transform` function will try to cast the string to the annotation type of the property.\n", "\n", @@ -354,7 +354,7 @@ " assert wanted_type is int\n", " return int(value) * 2\n", "\n", - "class ConfigurationVariable(ecological.AutoConfig, prefix=\"this_is_going_to_be_ignored\"):\n", + "class ConfigurationVariable(ecological.Config, prefix=\"this_is_going_to_be_ignored\"):\n", " integer = ecological.Variable(\"Integer\", transform=lambda v, wt: int(v))\n", " integer_x2: int = ecological.Variable(\"Integer\", transform=times_2)\n", " integer_as_str: str = ecological.Variable(\"Integer\", transform=lambda v, wt: v)\n", @@ -449,7 +449,7 @@ "source": [ "## Nested Configuration\n", "\n", - "`Ecological.AutoConfig` also supports nested configurations, for example:" + "`ecological.Config` also supports nested configurations, for example:" ] }, { @@ -463,10 +463,10 @@ "os.environ[\"INTEGER\"] = \"42\"\n", "os.environ[\"NESTED_BOOLEAN\"] = \"True\"\n", "\n", - "class ConfigurationNested(ecological.AutoConfig):\n", + "class ConfigurationNested(ecological.Config):\n", " integer: int\n", "\n", - " class Nested(ecological.AutoConfig, prefix='nested'):\n", + " class Nested(ecological.Config, prefix='nested'):\n", " boolean: bool" ] },