From 3571df1a74959ea2e5b2f462d0dc49e7bab9cd03 Mon Sep 17 00:00:00 2001 From: Guionardo Furlan Date: Tue, 28 Jun 2022 18:24:33 -0300 Subject: [PATCH] Fixed tests and dict exporting --- README.md | 166 +++++++++++++++++++++++++++++++ gs/__init__.py | 2 +- gs/cache/file_cache.py | 4 +- gs/config/base_config.py | 54 +++++++++- tests/config/test_base_config.py | 47 ++++++++- 5 files changed, 263 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 00709d4..cf7d6b0 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,11 @@ Tool classes and functions for Guiosoft projects ![PyPI - Downloads](https://img.shields.io/pypi/dm/py-gstools) [![Pylint](https://github.com/guionardo/py-gstools/actions/workflows/pylint.yml/badge.svg)](https://github.com/guionardo/py-gstools/actions/workflows/pylint.yml) +## Installing + +```bash +pip install py-gstools +``` ## Cache Cache wrapper for multiple providers. @@ -32,4 +37,165 @@ cache.set(key='key', value = cache.get('key') print(value) +``` + +## DotEnv + +Read environment variables from .env file + +(Yeah, I know there is a py-dotenv package, but this is small and better to my needs) + +Usage: + +```python +import os +from gs.dotenv import load_env + +# .env file +# CONFIGURATION_ONE=some_nasty_thing +# LOG_LEVEL=debug + +load_env(verbose=True) +``` + +```bash +2022-06-28 15:59:05,052 INFO load_env(file_name=.env - {'CONFIGURATION_ONE': 'some_nasty_thing', 'LOG_LEVEL': 'debug'}) +``` + +## Config + +Configuration classes from files or environment variables + +## Read data from environment variables + +```python +from gs.config import BaseConfig + +class EnvConfig(BaseConfig): + """Environment based config""" + + TESTING_ALPHA: str = 'alpha' # ENV:TEST_ALPHA + TESTING_BETA: str = 'beta' + TESTING_GAMMA: bool = False # ENV:TEST_GAMMA + +# Here, the environment variables must be available by the OS (by os.environ) + +# TESTING_ALPHA field maps to TEST_ALPHA environment var (defined in the comment) +# TESTING_BETA field maps to TESTING_BETA (default behavior, same to field name) +# TESTING_GAMMA fiels maps to TEST_GAMMA + +cfg = EnvConfig.load_from_env() + +print(cfg.sample_dict()) + +{'TESTING_BETA': 'beta', 'TEST_ALPHA': 'alpha', 'TEST_GAMMA': False} + +``` + +## Read data from file + +config.json file +```json +{ +"TESTING_BETA": "beta", +"TEST_ALPHA": "alpha", +"TEST_GAMMA": true +} +``` + +config.yaml file +```yaml +TESTING_BETA: beta +TEST_ALPHA: alpha +TEST_GAMMA: true +``` + +```python +from gs.config import BaseConfig + +class EnvConfig(BaseConfig): + """Environment based config""" + + TESTING_ALPHA: str = 'alpha' # ENV:TEST_ALPHA + TESTING_BETA: str = 'beta' + TESTING_GAMMA: bool = True # ENV:TEST_GAMMA + +cfg = EnvConfig.load_from_file('config.json') + +print(cfg.sample_dict()) + +{'TESTING_BETA': 'beta', 'TEST_ALPHA': 'alpha', 'TEST_GAMMA': True} + +``` + +## Composite configurations + +config.json file +```json +{ + "INT_ARG": 2, + "LIST_ARG": ["1", "2", "3", "4"], + "STR_ARG": "1234ABCD", + "SUB_CONFIG": { + "ARG_1": 10, + "ARG_2": "abc" + }, + "INT_ARG_2": 10, + "SUB_CONFIGS": [ + { + "ARG_1": 2, + "ARG_2": "EFGH" + }, + { + "ARG_1": 3, + "ARG_2": "IJKL" + } + ] +} +``` + +config.yaml file +```yaml +INT_ARG: 2 +INT_ARG_2: 10 +LIST_ARG: +- '1' +- '2' +- '3' +- '4' +STR_ARG: 1234ABCD +SUB_CONFIG: + ARG_1: 10 + ARG_2: ABCD +SUB_CONFIGS: +- ARG_1: 2 + ARG_2: EFGH +- ARG_1: 3 + ARG_2: IJKL +``` + +```python +from gs.config import BaseConfig + +class SubConfig(BaseConfig): + """Sample configuration class""" + ARG_1: int = 10 + ARG_2: str = 'abc' + + +class Config(BaseConfig): + """Sample configuration class""" + + INT_ARG: int = 1 # DOCUMENT FOR INT_ARG + INT_ARG_2: int + STR_ARG = 'abcd' # DOCUMENT FOR STR_ARG + LIST_ARG: List[str] = ['a', 'b', 'c', 'd'] + SUB_CONFIG: SubConfig + SUB_CONFIGS: List[SubConfig] + +cfg = EnvConfig.load_from_file('config.json') + +print(cfg.to_dict()) + +{'LIST_ARG': ['1', '2', '3', '4'], 'INT_ARG': 2, 'STR_ARG': '1234ABCD', 'SUB_CONFIG': {'ARG_2': 'abc', 'ARG_1': 2}, 'INT_ARG_2': 10, 'SUB_CONFIGS': [{'ARG_2': 'EFGH', 'ARG_1': 2}, {'ARG_2': 'IJKL', 'ARG_1': 3}]} ``` \ No newline at end of file diff --git a/gs/__init__.py b/gs/__init__.py index 13ef0f3..79aeb78 100644 --- a/gs/__init__.py +++ b/gs/__init__.py @@ -1,5 +1,5 @@ """Package data""" -__version__ = '0.1.6' +__version__ = '0.1.7' __tool_name__ = 'py-gstools' __description__ = 'Tool classes and functions for Guiosoft projects' __author__ = 'Guionardo Furlan' diff --git a/gs/cache/file_cache.py b/gs/cache/file_cache.py index 5016eed..7fb127c 100644 --- a/gs/cache/file_cache.py +++ b/gs/cache/file_cache.py @@ -1,5 +1,6 @@ """File Cache""" import datetime +import hashlib import json import logging import os @@ -17,7 +18,8 @@ def __init__(self) -> None: self.path = None def _filename(self, key: str) -> str: - return os.path.join(self.path, f'cache_{hash(key)}.json') + hash_name = hashlib.sha1(key.encode('utf-8')).hexdigest() + return os.path.join(self.path, f'cache_{hash_name}.json') def get(self, key: str) -> Union[str, None]: filename = self._filename(key) diff --git a/gs/config/base_config.py b/gs/config/base_config.py index 4aa2a16..332ffe9 100644 --- a/gs/config/base_config.py +++ b/gs/config/base_config.py @@ -37,7 +37,9 @@ def __parse_value_type(value, value_type): raise TypeError(f'Unknown type {value_type}') - def load(source: dict) -> any: + def load(source: dict, returns_field_name: bool = False) -> any: + if returns_field_name: + return field_name if field_name in source: source_value = source[field_name] if not is_list: @@ -46,7 +48,10 @@ def load(source: dict) -> any: source_value = [__parse_value_type(v, field_type) for v in source_value] else: - source_value = default_value + if not is_list or isinstance(default_value, list): + source_value = default_value + else: + source_value = [default_value] return source_value @@ -72,6 +77,16 @@ def __init__(self, source=None, **dict_source): self.__fields = self.__parse_fields() self.__load(source or dict_source) + @classmethod + def load_from_env(cls) -> 'BaseConfig': + """Load configuration from environment variables""" + return cls(**os.environ) + + @classmethod + def load_from_file(cls, filename: str) -> 'BaseConfig': + """Load configuration from file""" + return cls(filename) + def __parse_fields(self): default_values = get_fields_default_values(self) @@ -108,8 +123,10 @@ def __load(self, source): setattr(self, member, field(source)) def __parse_value_type(self, value_type, value): - if value_type in (int, float, bool, str): + if value_type in (int, float, str): return value_type(value) + if value_type == bool: + return value.lower()[0] in ('t', '1', 'y') if value_type == datetime: return datetime.strptime(value, '%Y-%m-%d %H:%M:%S') @@ -151,5 +168,32 @@ def __repr__(self) -> str: f"{k}={repr(getattr(self,k))}" for k in self.__fields) return f'{self.__class__.__name__}({fields})' - def __dict__(self) -> dict: - return + def sample_dict(self) -> dict: + """Return a sample dictionary with all fields and default values""" + def _sample_dict(obj): + if isinstance(obj, BaseConfig): + return obj.sample_dict() + if isinstance(obj, list): + return [_sample_dict(v) for v in obj] + return obj + res = {} + for _, field in self.__fields.items(): + res[field({}, True)] = _sample_dict(field({})) + return res + # return { + # field({}, True): _sample_dict(field({})) + # for _, field in self.__fields.items() + # } + + def to_dict(self) -> dict: + """Return a dictionary with all fields and values""" + def _to_dict(obj): + if isinstance(obj, BaseConfig): + return obj.to_dict() + if isinstance(obj, list): + return [_to_dict(v) for v in obj] + return obj + return { + field({}, True): _to_dict(getattr(self, member)) + for member, field in self.__fields.items() + } diff --git a/tests/config/test_base_config.py b/tests/config/test_base_config.py index b8ca1c3..a24bf8b 100644 --- a/tests/config/test_base_config.py +++ b/tests/config/test_base_config.py @@ -1,9 +1,10 @@ """Configuration tests""" import math -from typing import List +import os +import tempfile import unittest - -from datetime import datetime, date, timedelta +from datetime import date, datetime, timedelta +from typing import List from gs.config import BaseConfig @@ -25,6 +26,14 @@ class Config(BaseConfig): SUB_CONFIGS: List[SubConfig] +class EnvConfig(BaseConfig): + """Environment based config""" + + TESTING_ALPHA: str = 'alpha' # ENV:TEST_ALPHA + TESTING_BETA: str = 'beta' + TESTING_GAMMA: bool = False # ENV:TEST_GAMMA + + class ConfigTypes(BaseConfig): INT_ARG: int = 1 FLOAT_ARG: float = 1.0 @@ -56,3 +65,35 @@ def test_config(self): SUB_CONFIGS=[{'ARG_1': 2, 'ARG_2': 'EFGH'}, {'ARG_1': 3, 'ARG_2': 'IJKL'}]) self.assertEqual(cfg.INT_ARG, 2) self.assertEqual(cfg.SUB_CONFIG.ARG_1, 1) + + + def test_sample(self): + cfg = Config() + expected = {'INT_ARG': 1, + 'INT_ARG_2': 0, + 'LIST_ARG': ['a', 'b', 'c', 'd'], + 'STR_ARG': 'abcd', + 'SUB_CONFIG': {'ARG_1': 10, 'ARG_2': 'abc'}, + 'SUB_CONFIGS': [{'ARG_1': 10, 'ARG_2': 'abc'}]} + self.assertDictEqual(expected, cfg.sample_dict()) + + def test_load_from_env(self): + os.environ.update({ + 'TEST_ALPHA': 'ALPHA', + 'TESTING_BETA': 'BETA', + 'TEST_GAMMA': '1' + }) + cfg = EnvConfig.load_from_env() + self.assertEqual('ALPHA', cfg.TESTING_ALPHA) + self.assertEqual('BETA', cfg.TESTING_BETA) + self.assertTrue(cfg.TESTING_GAMMA) + + def test_load_from_file(self): + with tempfile.NamedTemporaryFile('w', delete=True) as tmp: + tmp.write( + '{"TEST_ALPHA": "ALPHA", "TESTING_BETA": "BETA", "TEST_GAMMA": true}') + tmp.flush() + cfg = EnvConfig.load_from_file(tmp.name) + self.assertEqual('ALPHA', cfg.TESTING_ALPHA) + self.assertEqual('BETA', cfg.TESTING_BETA) + self.assertTrue(cfg.TESTING_GAMMA)