├── .circleci └── config.yml ├── .gitignore ├── LICENSE.txt ├── MANIFEST ├── MANIFEST.in ├── README.md ├── python_settings ├── __init__.py ├── exceptions.py └── tests │ ├── __init__.py │ ├── settings │ ├── __init__.py │ ├── base_settings.py │ ├── development_settings.py │ └── lazy_settings.py │ └── test_python_settings.py ├── setup.cfg └── setup.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | workflows: 3 | version: 2 4 | test: 5 | jobs: 6 | - test-3.7 7 | - test-3.6 8 | - test-3.8 9 | - test-3.9 10 | - test-3.10 11 | - test-3.11 12 | - test-3.12 13 | - test-3.13 14 | jobs: 15 | test-3.7: &test-template 16 | docker: 17 | - image: cimg/python:3.7 18 | working_directory: ~/repo 19 | steps: 20 | - checkout 21 | - run: 22 | name: install dependencies 23 | command: | 24 | python3 -m venv venv 25 | . venv/bin/activate 26 | python -m pip install --upgrade pip 27 | pip install pytest 28 | 29 | 30 | - run: 31 | name: run tests 32 | command: | 33 | . venv/bin/activate 34 | cd python_settings 35 | pytest -vvv tests 36 | 37 | test-3.6: 38 | <<: *test-template 39 | docker: 40 | - image: cimg/python:3.6 41 | 42 | test-3.8: 43 | <<: *test-template 44 | docker: 45 | - image: cimg/python:3.8 46 | 47 | test-3.9: 48 | <<: *test-template 49 | docker: 50 | - image: cimg/python:3.9 51 | 52 | test-3.10: 53 | <<: *test-template 54 | docker: 55 | - image: cimg/python:3.10 56 | 57 | test-3.11: 58 | <<: *test-template 59 | docker: 60 | - image: cimg/python:3.11 61 | 62 | test-3.12: 63 | <<: *test-template 64 | docker: 65 | - image: cimg/python:3.12 66 | 67 | test-3.13: 68 | <<: *test-template 69 | docker: 70 | - image: cimg/python:3.13 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | \.idea/ 3 | 4 | *.pyc 5 | 6 | dist/ 7 | build/ 8 | venv/ 9 | 10 | python_settings.egg-info/ 11 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Carlos Perez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | # file GENERATED by distutils, do NOT edit 2 | README.md 3 | setup.cfg 4 | setup.py 5 | python_settings/__init__.py 6 | python_settings/exceptions.py 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CircleCI](https://circleci.com/gh/charlsagente/python-settings.svg?style=svg)](https://app.circleci.com/pipelines/github/charlsagente/python-settings) 2 | 3 | # python-settings 4 | This module provides you easy access to your **config/settings** properties from all your python modules, it supports normal and lazy initialization for each property. It is based on 5 | [django.conf.settings](https://github.com/django/django/blob/stable/1.11.x/django/conf/__init__.py#L58'). 6 | 7 | ## Installation 8 | From pip 9 | ```bash 10 | pip install python-settings 11 | ``` 12 | 13 | Or 14 | 15 | Clone this repo and type 16 | ```bash 17 | python setup.py install 18 | ``` 19 | 20 | ## How to configure 21 | 22 | Create a python file like **settings.py** in your project, the variable names must be in Capital Letters (A-Z), example: 23 | ```python 24 | # settings.py 25 | 26 | # Variables definition 27 | DATABASE_HOST = '10.0.0.1' 28 | 29 | DATABASE_NAME = 'DATABASENAME' 30 | 31 | ``` 32 | 33 | Two optional patterns to initialize this library 34 | 35 | * Option 1. Using an **environment variable**. 36 | You must have an environment variable called **SETTINGS_MODULE** and as a value your just created python settings file in the format {module}. 37 | {name}. With no .py extension. 38 | 39 | Example in bash: 40 | ```bash 41 | export SETTINGS_MODULE=settings 42 | ``` 43 | 44 | Example in Python 45 | 46 | ```python 47 | import os 48 | os.environ["SETTINGS_MODULE"] = 'settings' 49 | ``` 50 | 51 | * Option 2. Calling the configure function from our settings module and passing it your python file 52 | 53 | ```python 54 | from python_settings import settings 55 | from . import settings as my_local_settings 56 | 57 | settings.configure(my_local_settings) # configure() receives a python module 58 | assert settings.configured # now you are set 59 | ``` 60 | 61 | 62 | ## How to use 63 | 64 | Import the settings module and access directly to your properties: 65 | ```python 66 | from python_settings import settings 67 | 68 | print(settings.DATABASE_HOST) # Will print '10.0.0.1' 69 | print(settings.DATABASE_NAME) # Will print 'DATABASENAME' 70 | ``` 71 | 72 | ## Lazy Initialization 73 | 74 | Every time you start/restart your python project, 75 | all your defined variables are evaluated many times, 76 | if you are dealing with heavy to instantiate objects like 77 | database connections or similar network calls you will expect some delay. 78 | 79 | Using Lazy Initialization increases the performance of this process, 80 | changing the behavior of evaluating the variables only when is needed. 81 | 82 | ### Use the Lazy Initializer 83 | 84 | In your python settings file, you have to import our LazySetting class located in python_settings. 85 | 86 | 87 | ```python 88 | from python_settings import LazySetting 89 | from my_awesome_library import HeavyInitializationClass # Heavy to initialize object 90 | 91 | LAZY_INITIALIZATION = LazySetting(HeavyInitializationClass, "127.0.0.1:4222") 92 | # LazySetting(Class, *args, **kwargs) 93 | 94 | ``` 95 | 96 | Only the first time you call this property, the HeavyInitializationClass will be instantiated and the 97 | *args and **kwargs parameters will be passed. Every time you call this property the same instance will be returned. 98 | 99 | And now from any place in your code, you have to call the property 100 | ```python 101 | from python_settings import settings 102 | 103 | object_initialized = settings.LAZY_INITIALIZATION # Will return an instance of your object 104 | 105 | ``` 106 | 107 | ## Example for different environments 108 | You can use as many settings files as you need for different environments. 109 | Example for development environment settings: 110 | ```python 111 | # development_settings.py 112 | import os 113 | 114 | from .base_settings import * 115 | 116 | 117 | TOKEN_API = os.environ.get("TOKEN_API") 118 | 119 | 120 | ``` 121 | 122 | Example for testing environment 123 | ```python 124 | # testing_settings.py 125 | import os 126 | 127 | from .settings import * 128 | 129 | DATABASE_HOST = '10.0.0.1' 130 | 131 | TOKEN_API = os.environ.get("TOKEN_API") 132 | ``` 133 | 134 | And update your **SETTINGS_MODULE** variable 135 | ```bash 136 | export SETTINGS_MODULE = 'myproject.settings.testing_settings' 137 | ``` 138 | or use the config function 139 | 140 | TODO LIST: 141 | * Add function to update default environment variable name 142 | 143 | -------------------------------------------------------------------------------- /python_settings/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Based on Django Settings https://github.com/django/django/blob/stable/1.11.x/django/conf/__init__.py 3 | 4 | """ 5 | import importlib 6 | import os 7 | 8 | from python_settings.exceptions import ImproperlyConfigured, LazyInitializationImproperlyConfigured 9 | 10 | ENVIRONMENT_VARIABLE = "SETTINGS_MODULE" 11 | empty = object() 12 | 13 | 14 | class LazyProxy(object): 15 | def __init__(self, cls, *params, **kwargs): 16 | self.__dict__["_cls"] = cls 17 | self.__dict__["_params"] = params 18 | self.__dict__["_kwargs"] = kwargs 19 | 20 | self.__dict__["_obj"] = None 21 | 22 | def __call__(self): 23 | """ 24 | Initializes expensive object and returns it 25 | :return: Your custom object with parameters from LazySetting already initialized 26 | """ 27 | if self.__dict__["_obj"] is None: 28 | self.__dict__["_obj"] = self.__dict__["_cls"](*self.__dict__["_params"], **self.__dict__["_kwargs"]) 29 | 30 | return self.__dict__["_obj"] 31 | 32 | 33 | class LazyInit(object): 34 | def __new__(cls, new_object, *args, **kwargs): 35 | return LazyProxy(new_object, *args, **kwargs) 36 | 37 | 38 | class LazySetting(LazyInit): 39 | pass 40 | 41 | 42 | class BaseSettings(object): 43 | """ 44 | Common logic for settings whether set by a module or by the user 45 | """ 46 | 47 | def __setattr__(self, name, value): 48 | object.__setattr__(self, name, value) 49 | 50 | 51 | class Settings(BaseSettings): 52 | def __init__(self, settings_module): 53 | """ 54 | Configures all the settings overriding the provided by the user 55 | :param settings_module: User provided settings module (settings.py) 56 | """ 57 | # update this dict from global settings but only for CAPITALS settings 58 | try: 59 | self.SETTINGS_MODULE = settings_module 60 | mod = importlib.import_module( 61 | self.SETTINGS_MODULE) 62 | except ImportError as ie: 63 | raise ImproperlyConfigured("Cannot import SETTINGS_MODULE: {}".format(ie)) 64 | except Exception as e: 65 | raise ImproperlyConfigured("Error trying to import your settings module: {}".format(e)) 66 | for setting in dir(mod): 67 | if setting.isupper(): 68 | setting_value = getattr(mod, setting) 69 | setattr(self, setting, setting_value) 70 | 71 | 72 | class UserSettingsHolder: 73 | """Holder for user configured settings.""" 74 | 75 | def __init__(self, default_settings): 76 | """ 77 | Requests for configuration variables not in this class are satisfied 78 | from the module specified in default_settings (if possible). 79 | """ 80 | self.default_settings = default_settings 81 | 82 | def __getattr__(self, name): 83 | return getattr(self.default_settings, name) 84 | 85 | 86 | class SetupSettings(object): 87 | 88 | def __init__(self): 89 | self._wrapped = empty 90 | 91 | def _setup(self, name=None): 92 | """ 93 | Load the settings module pointed to by the env variable. 94 | 95 | """ 96 | settings_module = os.environ.get(ENVIRONMENT_VARIABLE) 97 | if not settings_module: 98 | desc = ("setting %s" % name) if name else "settings" 99 | raise ImproperlyConfigured( 100 | "Requested %s, but settings are not configured. " 101 | "You must either define the environment variable %s " 102 | "or call settings.configure() before accessing settings" 103 | % (desc, ENVIRONMENT_VARIABLE) 104 | ) 105 | self._wrapped = Settings(settings_module) 106 | 107 | def __getattr__(self, item): 108 | if self._wrapped is empty: 109 | self._setup(item) 110 | get_attr = getattr(self._wrapped, item) 111 | if isinstance(get_attr, LazyProxy): 112 | try: 113 | get_attr = get_attr() 114 | except Exception as ex: 115 | raise LazyInitializationImproperlyConfigured( 116 | "You didn't set your object properly" 117 | "You must use the LazySetting and pass your object without initializing it" 118 | "LazySetting(MyCustomClass, [params])" 119 | "Exception: %s - %s" % (type(ex), ex.__repr__()) 120 | ) 121 | return get_attr 122 | 123 | def configure(self, default_settings, **options): 124 | """ 125 | Called to manually configure the settings. The 'default_settings' 126 | parameter sets where to retrieve any unspecified values from 127 | (its arguments must support attribute access (__getattr__)) 128 | :param default_settings: 129 | :param options: 130 | :return: 131 | """ 132 | if self._wrapped is not empty: 133 | raise RuntimeError('Settings already configured.') 134 | else: 135 | self._wrapped = UserSettingsHolder(default_settings) 136 | 137 | @property 138 | def configured(self): 139 | """ 140 | Returns True if the settings have already been configured 141 | :return: True/False 142 | """ 143 | return self._wrapped is not empty 144 | 145 | 146 | settings = SetupSettings() 147 | 148 | __all__ = ["settings", "LazySetting"] 149 | -------------------------------------------------------------------------------- /python_settings/exceptions.py: -------------------------------------------------------------------------------- 1 | class ImproperlyConfigured(Exception): 2 | """ 3 | Something in the settings was not properly configured 4 | """ 5 | 6 | 7 | class LazyInitializationImproperlyConfigured(ImproperlyConfigured): 8 | """ 9 | Something in the LazySetting instantiation was not properly configured 10 | """ 11 | -------------------------------------------------------------------------------- /python_settings/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charlsagente/python-settings/1477add2338fad622c78a07c5f2824c6020021ac/python_settings/tests/__init__.py -------------------------------------------------------------------------------- /python_settings/tests/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charlsagente/python-settings/1477add2338fad622c78a07c5f2824c6020021ac/python_settings/tests/settings/__init__.py -------------------------------------------------------------------------------- /python_settings/tests/settings/base_settings.py: -------------------------------------------------------------------------------- 1 | URL_CONFIG ="www.python.org" 2 | DEFAULT_VALUE = 1 3 | DEFAULT_CONSTANT = 0 4 | 5 | -------------------------------------------------------------------------------- /python_settings/tests/settings/development_settings.py: -------------------------------------------------------------------------------- 1 | from .base_settings import * 2 | 3 | URL_CONFIG = "www.github.com" 4 | -------------------------------------------------------------------------------- /python_settings/tests/settings/lazy_settings.py: -------------------------------------------------------------------------------- 1 | class Task(object): 2 | 3 | def __init__(self, param): 4 | print("Executing hard to execute task initializer: %s" % param) 5 | 6 | 7 | import time 8 | 9 | 10 | class HeavyInitializationClass(object): 11 | def __init__(self, *args): 12 | time.sleep(1) 13 | print("One second delay after hard task") 14 | 15 | 16 | from python_settings import LazySetting 17 | 18 | LAZY_TASK = LazySetting(Task, "Making it lazy") 19 | 20 | LAZY_TASK_HEAVY_INITIALIZATION = LazySetting(HeavyInitializationClass, "127.0.0.1:4222") 21 | -------------------------------------------------------------------------------- /python_settings/tests/test_python_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | 5 | DEFAULT_PYTHON_SETTINGS = 'python_settings.tests.settings.base_settings' 6 | 7 | 8 | class TestPythonSettings(unittest.TestCase): 9 | 10 | def tearDown(self): 11 | try: 12 | del sys.modules['python_settings'] 13 | del sys.modules['python_settings.conf'] 14 | del sys.modules['python_settings.conf.exceptions'] 15 | del sys.modules['python_settings.conf.tests'] 16 | del sys.modules['python_settings.conf.tests.settings'] 17 | del sys.modules[DEFAULT_PYTHON_SETTINGS] 18 | 19 | except KeyError: 20 | pass 21 | 22 | @classmethod 23 | def setUpClass(cls): 24 | os.environ['SETTINGS_MODULE'] = DEFAULT_PYTHON_SETTINGS 25 | 26 | def test_config_compare_defaults_a(self): 27 | from python_settings import settings 28 | from python_settings.tests.settings.base_settings import URL_CONFIG, DEFAULT_VALUE, DEFAULT_CONSTANT 29 | self.assertEqual(settings.URL_CONFIG, URL_CONFIG) 30 | self.assertEqual(settings.DEFAULT_VALUE, DEFAULT_VALUE) 31 | self.assertEqual(settings.DEFAULT_CONSTANT, DEFAULT_CONSTANT) 32 | 33 | def test_config_compare_defaults_b(self): 34 | from python_settings import settings 35 | from python_settings.tests.settings.base_settings import URL_CONFIG, DEFAULT_VALUE, DEFAULT_CONSTANT 36 | self.assertEqual(settings.URL_CONFIG, URL_CONFIG) 37 | self.assertEqual(settings.DEFAULT_VALUE, DEFAULT_VALUE) 38 | self.assertEqual(settings.DEFAULT_CONSTANT, DEFAULT_CONSTANT) 39 | 40 | def test_attribute_error(self): 41 | from python_settings import settings 42 | # make sure AttributeError is thrown for missing Attribute 43 | with self.assertRaises(AttributeError) as a: 44 | print(settings.NOT_A_SETTING) 45 | # make sure we get the default value out of getattr 46 | self.assertEqual(getattr(settings, 'NOTHING', 'default'), 'default') 47 | 48 | def test_config_environment(self): 49 | from python_settings.tests.settings.base_settings import URL_CONFIG 50 | from python_settings import settings 51 | 52 | self.assertIsNotNone(settings.URL_CONFIG) 53 | self.assertEqual(os.environ.get('SETTINGS_MODULE'), DEFAULT_PYTHON_SETTINGS) 54 | self.assertEqual(settings.URL_CONFIG, URL_CONFIG) 55 | self.assertTrue(settings.configured) 56 | 57 | def test_config_new_environment(self): 58 | from python_settings.tests.settings.development_settings import URL_CONFIG 59 | from python_settings import settings 60 | development_settings = 'python_settings.tests.settings.development_settings' 61 | try: 62 | os.environ['SETTINGS_MODULE'] = development_settings 63 | except Exception: 64 | raise BaseException('Error: Trying to set the environment') 65 | self.assertEqual(os.environ.get('SETTINGS_MODULE'), development_settings) 66 | self.assertIsNotNone(settings.URL_CONFIG) 67 | self.assertEqual(settings.URL_CONFIG, URL_CONFIG) 68 | self.assertTrue(settings.configured) 69 | 70 | def test_lazy_config(self): 71 | 72 | from python_settings import settings 73 | 74 | try: 75 | os.environ['SETTINGS_MODULE'] = 'python_settings.tests.settings.lazy_settings' 76 | except Exception: 77 | raise BaseException('Error: Trying to set the environment') 78 | 79 | self.assertTrue(type(settings.LAZY_TASK)) 80 | self.assertTrue(type(settings.LAZY_TASK)) # For debugging purposes to check lazy initializer behavior 81 | 82 | self.assertTrue(settings.configured) 83 | 84 | def test_lazy_initialization(self): 85 | from python_settings import settings 86 | from python_settings.tests.settings import lazy_settings 87 | try: 88 | os.environ['SETTINGS_MODULE'] = 'python_settings.tests.settings.lazy_settings' 89 | except Exception: 90 | raise BaseException('Error: Trying to set the environment') 91 | 92 | settings.configure(default_settings=lazy_settings) 93 | self.assertTrue(settings.configured) 94 | self.assertTrue(settings.LAZY_TASK) 95 | self.assertTrue(settings.LAZY_TASK_HEAVY_INITIALIZATION) 96 | 97 | def test_wrong_settings_module(self): 98 | from python_settings import settings 99 | from python_settings import ImproperlyConfigured 100 | try: 101 | os.environ['SETTINGS_MODULE'] = 'python_settings.tests.wrong_settings_module' 102 | except Exception: 103 | raise BaseException('Error: Trying to set the environment') 104 | with self.assertRaises(ImproperlyConfigured) as c: 105 | print(settings.CONFIG) 106 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | # This includes the license file in the wheel. 3 | license_file = LICENSE.txt 4 | 5 | [bdist_wheel] 6 | # This flag says to generate wheels that support both Python 2 and Python 7 | # 3. If your code will not run unchanged on both Python 2 and 3, you will 8 | # need to generate separate wheels for each Python version that you 9 | # support. Removing this line (or setting universal to 0) will prevent 10 | # bdist_wheel from trying to make a universal wheel. For more see: 11 | # https://packaging.python.org/tutorials/distributing-packages/#wheels 12 | universal=1 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name='python-settings', 8 | version='0.2.3', 9 | packages=setuptools.find_packages(), 10 | url='https://github.com/charlsagente/python-settings', 11 | license='MIT', 12 | author='Carlos Perez', 13 | author_email='charlsagente@gmail.com', 14 | long_description=long_description, 15 | long_description_content_type="text/markdown", 16 | description='This module provides you easy access to your config/settings properties from all your python modules', 17 | classifiers=[ 18 | 'Intended Audience :: Developers', 19 | 'Operating System :: OS Independent', 20 | 'Topic :: Utilities', 21 | 'Programming Language :: Python', 22 | ] 23 | ) 24 | --------------------------------------------------------------------------------