diff --git a/src/npg/__init__.py b/src/npg/__init__.py new file mode 100644 index 0000000..27a5a18 --- /dev/null +++ b/src/npg/__init__.py @@ -0,0 +1,8 @@ +import importlib.metadata + +__version__ = importlib.metadata.version("npg-python-lib") + + +def version() -> str: + """Return the current version.""" + return __version__ diff --git a/src/npg/conf.py b/src/npg/conf.py index ede49c0..48a477d 100644 --- a/src/npg/conf.py +++ b/src/npg/conf.py @@ -182,6 +182,8 @@ def from_file( kwargs[field.name] = os.environ.get(env_var) - log.debug("Reading complete", dataclass=self.dataclass, kwargs=kwargs) + instance = self.dataclass(**kwargs) - return self.dataclass(**kwargs) + log.debug("Reading complete", instance=instance) + + return instance diff --git a/tests/test_conf.py b/tests/test_conf.py index ec6eebc..5270720 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -15,12 +15,14 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from dataclasses import dataclass +import logging +from dataclasses import dataclass, field from typing import Optional from unittest.mock import patch import pytest from pytest import mark as m +from structlog.testing import capture_logs from npg.conf import IniData, ParseError @@ -29,6 +31,7 @@ class ExampleConfig: """An example dataclass for testing.""" + secret: str = field(repr=False) key1: str key2: Optional[str] = None @@ -69,11 +72,13 @@ def test_populate_from_ini_file(self, tmp_path): section = "test" val1 = "value1" val2 = "value2" - ini_file.write_text(f"[{section}]\nkey1={val1}\nkey2={val2}\n") + ini_file.write_text( + f"[{section}]\nsecret=SECRET_VALUE\nkey1={val1}\nkey2={val2}\n" + ) parser = IniData(ExampleConfig) assert parser.from_file(ini_file, section) == ExampleConfig( - key1=val1, key2=val2 + key1=val1, key2=val2, secret="SECRET_VALUE" ) @m.context("When a field required by the dataclass is absent") @@ -94,10 +99,12 @@ def test_missing_non_required_value(self, tmp_path): ini_file = tmp_path / "config.ini" section = "test" val1 = "value1" - ini_file.write_text(f"[{section}]\nkey1={val1}\n") + ini_file.write_text(f"[{section}]\nsecret=SECRET_VALUE\nkey1={val1}\n") parser = IniData(ExampleConfig) - assert parser.from_file(ini_file, section) == ExampleConfig(key1=val1) + assert parser.from_file(ini_file, section) == ExampleConfig( + key1=val1, secret="SECRET_VALUE" + ) @m.context("When environment variables are not to be used") @m.it("Does not fall back to environment variables when a field is absent") @@ -105,13 +112,13 @@ def test_no_env_fallback(self, tmp_path): ini_file = tmp_path / "config.ini" section = "test" val1 = "value1" - ini_file.write_text(f"[{section}]\nkey1={val1}\n") + ini_file.write_text(f"[{section}]\nsecret=SECRET_VALUE\nkey1={val1}\n") env_val2 = "environment_value2" with patch.dict("os.environ", {"KEY2": env_val2}): parser = IniData(ExampleConfig, use_env=False) assert parser.from_file(ini_file, section) == ExampleConfig( - key1=val1, key2=None + secret="SECRET_VALUE", key1=val1, key2=None ) @m.context("When environment variables are to be used") @@ -120,13 +127,13 @@ def test_env_fallback(self, tmp_path): ini_file = tmp_path / "config.ini" section = "test" val1 = "value1" - ini_file.write_text(f"[{section}]\nkey1={val1}\n") + ini_file.write_text(f"[{section}]\nsecret=SECRET_VALUE\nkey1={val1}\n") env_val2 = "environment_value2" with patch.dict("os.environ", {"KEY2": env_val2}): parser = IniData(ExampleConfig, use_env=True) assert parser.from_file(ini_file, section) == ExampleConfig( - key1=val1, key2=env_val2 + key1=val1, key2=env_val2, secret="SECRET_VALUE" ) @m.context("When environment variables are to be used with a prefix") @@ -135,12 +142,45 @@ def test_env_fallback_with_prefix(self, tmp_path): ini_file = tmp_path / "config.ini" section = "test" val1 = "value1" - ini_file.write_text(f"[{section}]\nkey1={val1}\n") + ini_file.write_text(f"[{section}]\nsecret=SECRET_VALUE\nkey1={val1}\n") env_val2 = "environment_value2" with patch.dict("os.environ", {"EXAMPLE_KEY2": env_val2}): parser = IniData(ExampleConfig, use_env=True, env_prefix="EXAMPLE_") assert parser.from_file(ini_file, section) == ExampleConfig( - key1=val1, key2=env_val2 + key1=val1, key2=env_val2, secret="SECRET_VALUE" ) + + @m.context("When the configuration class includes a secret field") + @m.it("Does not include the secret field in the representation") + def test_secret_repr(self): + assert ( + repr(ExampleConfig(secret="SECRET_VALUE", key1="value1")) + == "ExampleConfig(key1='value1', key2=None)" + ) + + @m.context("When the configuration class includes a secret field") + @m.it("Does not include the secret field in the debug log") + def test_secret_debug(self, tmp_path, caplog): + ini_file = tmp_path / "config.ini" + section = "test" + val1 = "value1" + secret = "SECRET_VALUE" + ini_file.write_text(f"[{section}]\nsecret={secret}\nkey1={val1}\n") + + with caplog.at_level(logging.DEBUG): + with capture_logs() as cap_logs: + IniData(ExampleConfig).from_file(ini_file, section) + + found_log = False + found_secret = False + + for log in cap_logs: + if "event" in log and log["event"] == "Reading complete": + found_log = True + if str(log).find(secret) >= 0: + found_secret = True + + assert found_log + assert not found_secret