diff --git a/.gitignore b/.gitignore index 2e72e0b..84d0bf6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .cache/ *.egg-info/ +.env .python-version .venv/ diff --git a/poetry.lock b/poetry.lock index 195fcc9..d526e2e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -804,6 +804,17 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "pytest-xdist", "virtualenv"] +[[package]] +category = "main" +description = "Add .env support to your django/flask apps in development and deployments" +name = "python-dotenv" +optional = false +python-versions = "*" +version = "0.14.0" + +[package.extras] +cli = ["click (>=5.0)"] + [[package]] category = "dev" description = "World timezone definitions, modern and historical" @@ -1140,7 +1151,7 @@ optional = ["pygments", "colorama"] tests = ["pytest", "pytest-cov", "codecov", "scikit-build", "cmake", "ninja", "pybind11"] [metadata] -content-hash = "beb356287b912e1ce67c5997bd4d683dbc424ee24660a93d8f1fb4e511972762" +content-hash = "862a0bc650dab485af541f185ed3c1e86a8947e7518c8fbcacfd19d5b8055af5" lock-version = "1.0" python-versions = "^3.8" @@ -1526,6 +1537,10 @@ pytest-cov = [ {file = "pytest-cov-2.10.0.tar.gz", hash = "sha256:1a629dc9f48e53512fcbfda6b07de490c374b0c83c55ff7a1720b3fccff0ac87"}, {file = "pytest_cov-2.10.0-py2.py3-none-any.whl", hash = "sha256:6e6d18092dce6fad667cd7020deed816f858ad3b49d5b5e2b1cc1c97a4dba65c"}, ] +python-dotenv = [ + {file = "python-dotenv-0.14.0.tar.gz", hash = "sha256:8c10c99a1b25d9a68058a1ad6f90381a62ba68230ca93966882a4dbc3bc9c33d"}, + {file = "python_dotenv-0.14.0-py2.py3-none-any.whl", hash = "sha256:c10863aee750ad720f4f43436565e4c1698798d763b63234fb5021b6c616e423"}, +] pytz = [ {file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"}, {file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"}, diff --git a/pyproject.toml b/pyproject.toml index f4a571b..e636874 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ repository = "https://github.com/webartifex/urban-meal-delivery" python = "^3.8" click = "^7.1.2" +python-dotenv = "^0.14.0" [tool.poetry.dev-dependencies] # Task Runners diff --git a/setup.cfg b/setup.cfg index 3e1b9bf..3388dce 100644 --- a/setup.cfg +++ b/setup.cfg @@ -109,6 +109,11 @@ per-file-ignores = WPS213, # No overuse of string constants (e.g., '--version'). WPS226, + src/urban_meal_delivery/_config.py: + # Allow upper case class variables within classes. + WPS115, + # Numbers are normal in config files. + WPS432, tests/*.py: # Type annotations are not strictly enforced. ANN0, ANN2, @@ -135,10 +140,10 @@ show-source = true # wemake-python-styleguide's settings # =================================== allowed-domain-names = + obj, param, result, value, -min-name-length = 3 max-name-length = 40 # darglint strictness = long @@ -186,7 +191,13 @@ single_line_exclusions = typing [mypy] cache_dir = .cache/mypy -[mypy-nox.*,packaging,pytest] +[mypy-dotenv] +ignore_missing_imports = true +[mypy-nox.*] +ignore_missing_imports = true +[mypy-packaging] +ignore_missing_imports = true +[mypy-pytest] ignore_missing_imports = true diff --git a/src/urban_meal_delivery/__init__.py b/src/urban_meal_delivery/__init__.py index d130f04..9fdf819 100644 --- a/src/urban_meal_delivery/__init__.py +++ b/src/urban_meal_delivery/__init__.py @@ -6,8 +6,11 @@ Example: True """ +import os as _os from importlib import metadata as _metadata +from urban_meal_delivery import _config # noqa:WPS450 + try: _pkg_info = _metadata.metadata(__name__) @@ -21,3 +24,7 @@ else: __author__ = _pkg_info['author'] __pkg_name__ = _pkg_info['name'] __version__ = _pkg_info['version'] + + +# Little Hack: "Overwrites" the config module so that the environment is already set. +config = _config.get_config('testing' if _os.getenv('TESTING') else 'production') diff --git a/src/urban_meal_delivery/_config.py b/src/urban_meal_delivery/_config.py new file mode 100644 index 0000000..1be41ea --- /dev/null +++ b/src/urban_meal_delivery/_config.py @@ -0,0 +1,87 @@ +"""Provide package-wide configuration. + +This module is "protected" so that it is only used +via the `config` proxy at the package's top level. + +That already loads the correct configuration +depending on the current environment. +""" + +import datetime +import os +import warnings + +import dotenv + + +dotenv.load_dotenv() + + +class Config: + """Configuration that applies in all situations.""" + + # pylint:disable=too-few-public-methods + + CUTOFF_DAY = datetime.datetime(2017, 2, 1) + + # If a scheduled pre-order is made within this + # time horizon, we treat it as an ad-hoc order. + QUASI_AD_HOC_LIMIT = datetime.timedelta(minutes=45) + + DATABASE_URI = os.getenv('DATABASE_URI') + + # The PostgreSQL schema that holds the tables with the original data. + ORIGINAL_SCHEMA = os.getenv('ORIGINAL_SCHEMA') or 'public' + + # The PostgreSQL schema that holds the tables with the cleaned data. + CLEAN_SCHEMA = os.getenv('CLEAN_SCHEMA') or 'clean' + + def __repr__(self) -> str: + """Non-literal text representation.""" + return '' + + +class ProductionConfig(Config): + """Configuration for the real dataset.""" + + # pylint:disable=too-few-public-methods + + TESTING = False + + +class TestingConfig(Config): + """Configuration for the test suite.""" + + # pylint:disable=too-few-public-methods + + TESTING = True + + DATABASE_URI = os.getenv('DATABASE_URI_TESTING') or Config.DATABASE_URI + CLEAN_SCHEMA = os.getenv('CLEAN_SCHEMA_TESTING') or Config.CLEAN_SCHEMA + + +def get_config(env: str = 'production') -> Config: + """Get the configuration for the package. + + Args: + env: either 'production' or 'testing'; defaults to the first + + Returns: + config: a namespace with all configurations + + Raises: + ValueError: if `env` is not as specified + """ # noqa:DAR203 + config: Config + if env.strip().lower() == 'production': + config = ProductionConfig() + elif env.strip().lower() == 'testing': + config = TestingConfig() + else: + raise ValueError("Must be either 'production' or 'testing'") + + # Without a PostgreSQL database the package cannot work. + if config.DATABASE_URI is None: + warnings.warn('Bad configurartion: no DATABASE_URI set in the environment') + + return config diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..8983808 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,45 @@ +"""Test the package's configuration module.""" + +import pytest + +from urban_meal_delivery import _config as config_mod # noqa:WPS450 + + +envs = ['production', 'testing'] + + +@pytest.mark.parametrize('env', envs) +def test_config_repr(env): + """Config objects have the text representation ''.""" + config = config_mod.get_config(env) + + assert str(config) == '' + + +def test_invalid_config(): + """There are only 'production' and 'testing' configurations.""" + with pytest.raises(ValueError, match="'production' or 'testing'"): + config_mod.get_config('invalid') + + +@pytest.mark.parametrize('env', envs) +def test_database_uri_set(env, monkeypatch): + """Package does NOT emit warning if DATABASE_URI is set.""" + uri = 'postgresql://user:password@localhost/db' + monkeypatch.setattr(config_mod.ProductionConfig, 'DATABASE_URI', uri) + monkeypatch.setattr(config_mod.TestingConfig, 'DATABASE_URI', uri) + + with pytest.warns(None) as record: + config_mod.get_config(env) + + assert len(record) == 0 # noqa:WPS441,WPS507 + + +@pytest.mark.parametrize('env', envs) +def test_no_database_uri_set(env, monkeypatch): + """Package does not work without DATABASE_URI set in the environment.""" + monkeypatch.setattr(config_mod.ProductionConfig, 'DATABASE_URI', None) + monkeypatch.setattr(config_mod.TestingConfig, 'DATABASE_URI', None) + + with pytest.warns(UserWarning, match='no DATABASE_URI'): + config_mod.get_config(env)