From b5e471b181a193f4f2fa308f2d893aa75bb66a09 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 1 Jun 2026 00:27:53 +0300 Subject: [PATCH] docs: document and pin BaseConfig.from_dict / from_object semantics The two builder classmethods on BaseConfig have intentionally asymmetric semantics that aren't obvious from reading the code: - from_dict includes any key present in the dict (explicit None overrides the default); unknown keys are silently dropped. - from_object includes only attributes whose value is not None; attributes set to None or missing entirely fall back to the dataclass default. Falsy non-None values (False, "", []) are preserved. Add one-line docstrings capturing each method's contract. Condense the from_object body to a single dict-comprehension matching from_dict's style; behavior is identical (verified by the new pinning tests). Add four pinning tests on top of the two pre-existing tests: - test_from_object_skips_none_attribute - test_from_object_skips_missing_attribute - test_from_object_preserves_falsy_values - test_from_dict_drops_unknown_keys_silently Closes DES-3, TEST-5, TEST-6 from the audit. --- lite_bootstrap/instruments/base.py | 8 +++--- tests/test_config.py | 43 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/lite_bootstrap/instruments/base.py b/lite_bootstrap/instruments/base.py index 04d2410..32256ea 100644 --- a/lite_bootstrap/instruments/base.py +++ b/lite_bootstrap/instruments/base.py @@ -15,17 +15,15 @@ class BaseConfig: @classmethod def from_dict(cls, data: dict[str, typing.Any]) -> typing_extensions.Self: + """Build a config from a dict; unknown keys are silently dropped, explicit None overrides defaults.""" field_names = {f.name for f in dataclasses.fields(cls)} return cls(**{k: v for k, v in data.items() if k in field_names}) @classmethod def from_object(cls, obj: object) -> typing_extensions.Self: - prepared_data = {} + """Build a config by merging non-None attributes from obj; None or missing attributes fall back to defaults.""" field_names = {f.name for f in dataclasses.fields(cls)} - - for field in field_names: - if (value := getattr(obj, field, None)) is not None: - prepared_data[field] = value + prepared_data = {field: value for field in field_names if (value := getattr(obj, field, None)) is not None} return cls(**prepared_data) diff --git a/tests/test_config.py b/tests/test_config.py index bffa7f8..27f5d56 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -49,3 +49,46 @@ def test_config_from_object() -> None: short_config = BaseConfig.from_object(big_config) for field in dataclasses.fields(BaseConfig): assert getattr(short_config, field.name) == getattr(big_config, field.name) + + +def test_from_object_skips_none_attribute() -> None: + @dataclasses.dataclass + class Source: + service_name: str | None = None + service_version: str = "2.0.0" + + config = BaseConfig.from_object(Source()) + assert config.service_name == "micro-service" + assert config.service_version == "2.0.0" + + +def test_from_object_skips_missing_attribute() -> None: + class Source: + pass + + config = BaseConfig.from_object(Source()) + assert config.service_name == "micro-service" + assert config.service_version == "1.0.0" + assert config.service_debug is True + + +def test_from_object_preserves_falsy_values() -> None: + @dataclasses.dataclass + class Source: + service_name: str = "" + service_debug: bool = False + + config = BaseConfig.from_object(Source()) + assert config.service_name == "" + assert config.service_debug is False + + +def test_from_dict_drops_unknown_keys_silently() -> None: + config = BaseConfig.from_dict({"service_name": "test", "unknown_key": "value"}) + assert config.service_name == "test" + assert config.service_version == "1.0.0" + + +def test_from_dict_explicit_none_overrides_default() -> None: + config = BaseConfig.from_dict({"service_name": None}) + assert config.service_name is None