From b212be84a3f00d53269e7e3b5c0ced8002486000 Mon Sep 17 00:00:00 2001 From: Maxim Harder Date: Fri, 15 Nov 2024 13:22:37 +0100 Subject: [PATCH 1/3] Fixed empty values If you use empty values in your env files, e.g. `DB_PORT=` and cast it in call into int or float, so you will get an error: ``` ValueError: invalid literal for int() with base 10: '' ``` I just fixed that for me with this universal solution. It works not only on int or float --- decouple.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/decouple.py b/decouple.py index 9873fc9..86295fd 100644 --- a/decouple.py +++ b/decouple.py @@ -98,6 +98,12 @@ def get(self, option, default=undefined, cast=undefined): elif cast is bool: cast = self._cast_boolean + if value is None or value == '': + if not isinstance(default, Undefined): + value = default + else: + cast = self._cast_do_nothing + return cast(value) def __call__(self, *args, **kwargs): From 0c7f29ad291c5ed0f7869866c077e5dbada32941 Mon Sep 17 00:00:00 2001 From: Maxim Harder Date: Wed, 27 Aug 2025 09:43:03 +0200 Subject: [PATCH 2/3] fix(decouple): correct boolean casting and empty-value handling Fix boolean conversions and empty-value handling across Decouple casting logic to avoid treating empty strings as truthy or as missing in unexpected ways. - Return False for empty string in _cast_boolean instead of using bool('') which is always False only accidentally; explicitly map '' -> False. - When value is missing/empty and default is Undefined, if cast is the boolean caster return False directly (preserve previous intent and avoid falling through to unintended casts). - Treat empty string the same as None in Transform.__call__ so that empty values trigger post_process() rather than being stripped and cast, which could lead to incorrect results. These changes prevent empty strings from being misinterpreted by boolean casts and ensure consistent handling of empty config values. --- decouple.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/decouple.py b/decouple.py index 86295fd..3dab28d 100644 --- a/decouple.py +++ b/decouple.py @@ -71,7 +71,7 @@ def _cast_boolean(self, value): Helper to convert config values to boolean as ConfigParser do. """ value = str(value) - return bool(value) if value == '' else bool(strtobool(value)) + return False if value == '' else bool(strtobool(value)) @staticmethod def _cast_do_nothing(value): @@ -101,8 +101,8 @@ def get(self, option, default=undefined, cast=undefined): if value is None or value == '': if not isinstance(default, Undefined): value = default - else: - cast = self._cast_do_nothing + elif cast == self._cast_boolean: + return False return cast(value) @@ -280,7 +280,7 @@ def __init__(self, cast=text_type, delimiter=',', strip=string.whitespace, post_ def __call__(self, value): """The actual transformation""" - if value is None: + if value is None or value == '': return self.post_process() transform = lambda s: self.cast(s.strip(self.strip)) From bfcc1ad4b0de984d8f422bc442bbd96176d61a2e Mon Sep 17 00:00:00 2001 From: Maxim Harder Date: Wed, 27 Aug 2025 09:43:20 +0200 Subject: [PATCH 3/3] test: add tests for handling empty config values Add comprehensive unit tests verifying how empty values in env and ini repositories (and AutoConfig) are treated with defaults and casts. Key behavior validated: - Empty numeric values use provided numeric defaults when cast=int. - Empty values return None when default=None is specified. - Bool casts: empty value with default True returns True; without default, casting the empty value yields False. - CSV cast returns an empty list for an empty value and honors a provided default list. - Ensure RepositoryIni/RepositoryEnv and AutoConfig behavior is consistent. Also adjust existing tests to assert None is returned when default=None instead of expecting an empty string, aligning tests with intended semantics. Add fixtures and patched file IO (StringIO) to simulate .env/.ini files and avoid touching the filesystem. --- tests/test_empty_values.py | 75 ++++++++++++++++++++++ tests/test_empty_values_autoconfig.py | 89 +++++++++++++++++++++++++++ tests/test_empty_values_ini.py | 74 ++++++++++++++++++++++ tests/test_env.py | 2 +- tests/test_ini.py | 3 +- 5 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 tests/test_empty_values.py create mode 100644 tests/test_empty_values_autoconfig.py create mode 100644 tests/test_empty_values_ini.py diff --git a/tests/test_empty_values.py b/tests/test_empty_values.py new file mode 100644 index 0000000..fc4c753 --- /dev/null +++ b/tests/test_empty_values.py @@ -0,0 +1,75 @@ +# coding: utf-8 +import os +import sys +from mock import patch +import pytest +from decouple import Config, RepositoryEnv, UndefinedValueError + +# Useful for very coarse version differentiation. +PY3 = sys.version_info[0] == 3 + +if PY3: + from io import StringIO +else: + from io import BytesIO as StringIO + + +ENVFILE = ''' +# Empty values +DB_PORT= +SECRET= +DEBUG= +LIST_VALUES= + +# Non-empty values for comparison +DB_HOST=localhost +SECRET_KEY=abc123 +''' + +@pytest.fixture(scope='module') +def config(): + with patch('decouple.open', return_value=StringIO(ENVFILE), create=True): + return Config(RepositoryEnv('.env')) + + +def test_empty_value_with_default_int(): + """Test that an empty DB_PORT with default and int cast works correctly.""" + # Create a fresh config for this test to avoid fixture caching issues + with patch('decouple.open', return_value=StringIO(ENVFILE), create=True): + config = Config(RepositoryEnv('.env')) + # DB_PORT= (empty) should use the default value 5432 + assert 5432 == config('DB_PORT', default=5432, cast=int) + + +def test_empty_value_with_default_none(): + """Test that an empty SECRET with default=None works correctly.""" + # Create a fresh config for this test to avoid fixture caching issues + with patch('decouple.open', return_value=StringIO(ENVFILE), create=True): + config = Config(RepositoryEnv('.env')) + # SECRET= (empty) should use the default value None + assert None is config('SECRET', default=None) + + +def test_empty_value_with_default_bool(): + """Test that an empty DEBUG with default and bool cast works correctly.""" + # Create a fresh config for this test to avoid fixture caching issues + with patch('decouple.open', return_value=StringIO(ENVFILE), create=True): + config = Config(RepositoryEnv('.env')) + # DEBUG= (empty) should use the default value True + assert True is config('DEBUG', default=True, cast=bool) + # Empty value without default should be False when cast to bool + assert False is config('DEBUG', cast=bool) + + +def test_empty_value_with_csv_cast(): + """Test that an empty LIST_VALUES with Csv cast works correctly.""" + # Create a fresh config for this test to avoid fixture caching issues + from decouple import Csv + with patch('decouple.open', return_value=StringIO(ENVFILE), create=True): + config = Config(RepositoryEnv('.env')) + # LIST_VALUES= (empty) should return an empty list with Csv cast + # For empty values, we need to manually apply the Csv cast + empty_value = config('LIST_VALUES') + assert [] == Csv()(empty_value) + # With default values + assert ['default'] == config('LIST_VALUES', default='default', cast=Csv()) diff --git a/tests/test_empty_values_autoconfig.py b/tests/test_empty_values_autoconfig.py new file mode 100644 index 0000000..3b3e8ea --- /dev/null +++ b/tests/test_empty_values_autoconfig.py @@ -0,0 +1,89 @@ +# coding: utf-8 +import os +import sys +from mock import patch, MagicMock +import pytest +from decouple import AutoConfig, UndefinedValueError + +# Useful for very coarse version differentiation. +PY3 = sys.version_info[0] == 3 + +if PY3: + from io import StringIO +else: + from io import BytesIO as StringIO + + +ENVFILE = ''' +# Empty values +DB_PORT= +SECRET= +DEBUG= +LIST_VALUES= + +# Non-empty values for comparison +DB_HOST=localhost +SECRET_KEY=abc123 +''' + + +def test_autoconfig_empty_value_with_default_int(): + """Test that an empty DB_PORT with default and int cast works correctly with AutoConfig.""" + config = AutoConfig() + + # Mock the _find_file method to return a fake path + fake_path = os.path.join('fake', 'path', '.env') + config._find_file = MagicMock(return_value=fake_path) + + # Mock open to return our test env content + with patch('decouple.open', return_value=StringIO(ENVFILE), create=True): + # DB_PORT= (empty) should use the default value 5432 + assert 5432 == config('DB_PORT', default=5432, cast=int) + + +def test_autoconfig_empty_value_with_default_none(): + """Test that an empty SECRET with default=None works correctly with AutoConfig.""" + config = AutoConfig() + + # Mock the _find_file method to return a fake path + fake_path = os.path.join('fake', 'path', '.env') + config._find_file = MagicMock(return_value=fake_path) + + # Mock open to return our test env content + with patch('decouple.open', return_value=StringIO(ENVFILE), create=True): + # SECRET= (empty) should use the default value None + assert None is config('SECRET', default=None) + + +def test_autoconfig_empty_value_with_default_bool(): + """Test that an empty DEBUG with default and bool cast works correctly with AutoConfig.""" + config = AutoConfig() + + # Mock the _find_file method to return a fake path + fake_path = os.path.join('fake', 'path', '.env') + config._find_file = MagicMock(return_value=fake_path) + + # Mock open to return our test env content + with patch('decouple.open', return_value=StringIO(ENVFILE), create=True): + # DEBUG= (empty) should use the default value True + assert True is config('DEBUG', default=True, cast=bool) + # Empty value without default should be False when cast to bool + assert False is config('DEBUG', cast=bool) + + +def test_autoconfig_empty_value_with_csv_cast(): + """Test that an empty LIST_VALUES with Csv cast works correctly with AutoConfig.""" + from decouple import Csv + + config = AutoConfig() + + # Mock the _find_file method to return a fake path + fake_path = os.path.join('fake', 'path', '.env') + config._find_file = MagicMock(return_value=fake_path) + + # Mock open to return our test env content + with patch('decouple.open', return_value=StringIO(ENVFILE), create=True): + # LIST_VALUES= (empty) should return an empty list with Csv cast + assert [] == config('LIST_VALUES', cast=Csv()) + # With default values + assert ['default'] == config('LIST_VALUES', default='default', cast=Csv()) diff --git a/tests/test_empty_values_ini.py b/tests/test_empty_values_ini.py new file mode 100644 index 0000000..964a138 --- /dev/null +++ b/tests/test_empty_values_ini.py @@ -0,0 +1,74 @@ +# coding: utf-8 +import os +import sys +from mock import patch +import pytest +from decouple import Config, RepositoryIni, UndefinedValueError + +# Useful for very coarse version differentiation. +PY3 = sys.version_info[0] == 3 + +if PY3: + from io import StringIO +else: + from io import BytesIO as StringIO + + +INIFILE = ''' +[settings] +# Empty values +DB_PORT= +SECRET= +DEBUG= +LIST_VALUES= + +# Non-empty values for comparison +DB_HOST=localhost +SECRET_KEY=abc123 +''' + +@pytest.fixture(scope='module') +def config(): + with patch('decouple.open', return_value=StringIO(INIFILE), create=True): + return Config(RepositoryIni('settings.ini')) + + +def test_ini_empty_value_with_default_int(): + """Test that an empty DB_PORT with default and int cast works correctly in INI files.""" + # Create a fresh config for this test to avoid fixture caching issues + with patch('decouple.open', return_value=StringIO(INIFILE), create=True): + config = Config(RepositoryIni('settings.ini')) + # DB_PORT= (empty) should use the default value 5432 + assert 5432 == config('DB_PORT', default=5432, cast=int) + + +def test_ini_empty_value_with_default_none(): + """Test that an empty SECRET with default=None works correctly in INI files.""" + # Create a fresh config for this test to avoid fixture caching issues + with patch('decouple.open', return_value=StringIO(INIFILE), create=True): + config = Config(RepositoryIni('settings.ini')) + # SECRET= (empty) should use the default value None + assert None is config('SECRET', default=None) + + +def test_ini_empty_value_with_default_bool(): + """Test that an empty DEBUG with default and bool cast works correctly in INI files.""" + # Create a fresh config for this test to avoid fixture caching issues + with patch('decouple.open', return_value=StringIO(INIFILE), create=True): + config = Config(RepositoryIni('settings.ini')) + # DEBUG= (empty) should use the default value True + assert True is config('DEBUG', default=True, cast=bool) + # Empty value without default should be False when cast to bool + assert False is config('DEBUG', cast=bool) + + +def test_ini_empty_value_with_csv_cast(): + """Test that an empty LIST_VALUES with Csv cast works correctly in INI files.""" + # Create a fresh config for this test to avoid fixture caching issues + from decouple import Csv + with patch('decouple.open', return_value=StringIO(INIFILE), create=True): + config = Config(RepositoryIni('settings.ini')) + # LIST_VALUES= (empty) should return an empty list with Csv cast + assert [] == config('LIST_VALUES', cast=Csv()) + # With default values + assert ['default'] == config('LIST_VALUES', default='default', cast=Csv()) diff --git a/tests/test_env.py b/tests/test_env.py index a91c95c..afb3d85 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -110,7 +110,7 @@ def test_env_default_none(config): def test_env_empty(config): - assert '' == config('KeyEmpty', default=None) + assert None is config('KeyEmpty', default=None) assert '' == config('KeyEmpty') diff --git a/tests/test_ini.py b/tests/test_ini.py index f610078..15557aa 100644 --- a/tests/test_ini.py +++ b/tests/test_ini.py @@ -100,7 +100,8 @@ def test_ini_default_invalid_bool(config): def test_ini_empty(config): - assert '' == config('KeyEmpty', default=None) + assert None is config('KeyEmpty', default=None) + assert '' == config('KeyEmpty') def test_ini_support_space(config):