diff --git a/CHANGELOG.md b/CHANGELOG.md index a0cc9e4..eba61ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.3.1 - 2026-09-08 + +### Added + +- `gfmodules.logging.ini.split_comma_separated()` builds a pydantic + `mode="before"` validator that splits a comma-separated INI value into a + list, for an application whose own config loader reads INI. Opt-in per + field; `ConfigLogging` itself stays plain `list[str]`. + ## 0.3.0 - 2026-09-07 ### Changed (breaking) diff --git a/docs/STARTING_GUIDE.md b/docs/STARTING_GUIDE.md index 5117895..f3f069d 100644 --- a/docs/STARTING_GUIDE.md +++ b/docs/STARTING_GUIDE.md @@ -152,6 +152,36 @@ with its records going nowhere. The raised error names `syslog_path` and the value it was given. Point it at a host reachable from wherever the process runs, which for a container service name means from inside the compose network. +### Loading list settings from INI files + +`console_streams` is `list[str]`; pydantic does not turn a plain string into a +list, so a `ConfigLogging` populated from an INI file (where every value is a +string) needs the setting to arrive already split. An application loading its +own config from INI opts the field in explicitly: + +```python +from gfmodules.logging import ConfigLogging as GFConfigLogging +from gfmodules.logging.ini import split_comma_separated +from pydantic import field_validator + +class ConfigLogging(GFConfigLogging): + _split_console_streams = field_validator("console_streams", mode="before")( + split_comma_separated() + ) +``` + +`split_comma_separated` takes each stripped item through `item_type` (`str` by +default), so an application's own comma-separated INI setting can reuse it too: + +```python +_split_retry_backoff = field_validator("retry_backoff", mode="before")( + split_comma_separated(float) +) +``` + +`ConfigLogging` itself stays plain `list[str]`, agnostic to where its data +comes from — this is opt-in per application, not automatic. + ### The logger tree The stream handlers are attached to one logger tree, named `app` by default. diff --git a/gfmodules/logging/ini.py b/gfmodules/logging/ini.py new file mode 100644 index 0000000..05a699e --- /dev/null +++ b/gfmodules/logging/ini.py @@ -0,0 +1,47 @@ +"""Adapter for applications that populate config models from INI-style files, +where every value is a flat string and there is no native list syntax. + +Nothing in this library needs this: ``ConfigLogging`` stays plain ``list[str]``, +agnostic to where its data comes from. An application whose config loader reads +INI opts in per field instead. +""" + +from typing import Any, Callable + +__all__ = ["split_comma_separated"] + + +def split_comma_separated(item_type: type[Any] = str) -> Callable[[Any], Any]: + """Build a pydantic ``mode="before"`` validator that splits a comma-separated + string into a list, converting each item through `item_type`. Value that + is not a string (already a list, from a non-INI source, or in a test) + passes through unchanged. + + Usage: + + ```python + from gfmodules.logging import ConfigLogging as GFConfigLogging + from gfmodules.logging.ini import split_comma_separated + from pydantic import field_validator + + class ConfigLogging(GFConfigLogging): + _split_console_streams = field_validator("console_streams", mode="before")( + split_comma_separated() + ) + ``` + + `item_type` converts each stripped piece, for a field that isn't `list[str]`: + + ```python + _split_retry_backoff = field_validator("retry_backoff", mode="before")( + split_comma_separated(float) + ) + ``` + """ + + def _validate(value: Any) -> Any: + if isinstance(value, str): + return [item_type(item.strip()) for item in value.split(",") if item.strip()] + return value + + return _validate diff --git a/pyproject.toml b/pyproject.toml index 86140d3..625901a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gfmodules-python-logging-library" -version = "0.3.0" +version = "0.3.1" description = "Provides logging utilities for GFModules Python projects." license = "EUPL-1.2" authors = [{ name = "Ministerie van Volksgezondheid, Welzijn en Sport" }] diff --git a/tests/test_ini.py b/tests/test_ini.py new file mode 100644 index 0000000..7915e05 --- /dev/null +++ b/tests/test_ini.py @@ -0,0 +1,67 @@ +from pydantic import BaseModel, field_validator + +from gfmodules.logging.ini import split_comma_separated + + +class TestSplitCommaSeparated: + def test_splits_a_comma_separated_string(self) -> None: + validate = split_comma_separated() + + assert validate("app,siem,debug") == ["app", "siem", "debug"] + + def test_strips_whitespace_around_items(self) -> None: + validate = split_comma_separated() + + assert validate(" app , siem ,debug ") == ["app", "siem", "debug"] + + def test_drops_empty_items(self) -> None: + validate = split_comma_separated() + + assert validate("app,,siem,") == ["app", "siem"] + + def test_a_single_value_becomes_a_one_item_list(self) -> None: + validate = split_comma_separated() + + assert validate("debug") == ["debug"] + + def test_an_empty_string_becomes_an_empty_list(self) -> None: + validate = split_comma_separated() + + assert validate("") == [] + + def test_a_non_string_value_passes_through_unchanged(self) -> None: + validate = split_comma_separated() + already_a_list = ["app", "siem"] + + assert validate(already_a_list) is already_a_list + + def test_converts_items_through_the_given_item_type(self) -> None: + validate = split_comma_separated(float) + + assert validate("0.1, 0.2, 0.4") == [0.1, 0.2, 0.4] + + def test_a_non_string_value_skips_item_type_conversion(self) -> None: + validate = split_comma_separated(float) + already_converted = [0.1, 0.2] + + assert validate(already_converted) is already_converted + + +class TestFieldValidatorIntegration: + def test_wires_into_a_pydantic_model_as_a_before_validator(self) -> None: + class Config(BaseModel): + console_streams: list[str] = ["app", "siem"] + + _split_console_streams = field_validator("console_streams", mode="before")(split_comma_separated()) + + assert Config.model_validate({"console_streams": "app,debug"}).console_streams == ["app", "debug"] + assert Config(console_streams=["app", "debug"]).console_streams == ["app", "debug"] + assert Config().console_streams == ["app", "siem"] + + def test_wires_into_a_pydantic_model_with_a_typed_item_converter(self) -> None: + class Config(BaseModel): + retry_backoff: list[float] = [0.1, 0.2] + + _split_retry_backoff = field_validator("retry_backoff", mode="before")(split_comma_separated(float)) + + assert Config.model_validate({"retry_backoff": "0.1,0.2,0.4"}).retry_backoff == [0.1, 0.2, 0.4]