Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions nerve/coerce.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ def _scalar_to_list(value: Any, base: type) -> list[Any] | None:
A blank string yields the empty list rather than one empty entry, and a lone
non-string scalar (``exclude_chats: 42``) is wrapped so it reaches the
element coercion below.

A tuple is copied element-wise. YAML never parses to one, so a tuple can
only be a code-side default — a builder handing a module-level tuple
constant to a ``list[X]`` field — where the elements are already the
intended entries and only the container is wrong.
"""
if value is None:
return []
Expand All @@ -177,6 +182,8 @@ def _scalar_to_list(value: Any, base: type) -> list[Any] | None:
return [part.strip() for part in value.split(",") if part.strip()]
if isinstance(value, (bool, int, float)):
return [value]
if isinstance(value, tuple):
return list(value)
return None


Expand Down
14 changes: 9 additions & 5 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2473,15 +2473,19 @@ def from_dict(cls, d: dict) -> "LangfuseConfig":
# A bare ``redact_patterns:`` parses to None, which the shared list
# handling reads as the empty list. That is the right answer for most
# fields and the wrong one here, because it would turn secret redaction
# off on a blank line. An explicit ``[]`` still means off.
patterns = d.get("redact_patterns", _DEFAULT_LANGFUSE_REDACT_PATTERNS)
# off on a blank line. An explicit ``[]`` still means off. The default
# is materialized as a ``list`` so the field arrives as its declared
# type — handing the tuple through made ``coerce_scalars`` warn about
# a "non-list config value" on every load of a config that never
# mentions Langfuse.
patterns = d.get("redact_patterns")
if patterns is None:
patterns = list(_DEFAULT_LANGFUSE_REDACT_PATTERNS)
return cls(
public_key=d.get("public_key", ""),
secret_key=d.get("secret_key", ""),
host=d.get("host", "https://cloud.langfuse.com"),
redact_patterns=_str_list(
_DEFAULT_LANGFUSE_REDACT_PATTERNS if patterns is None else patterns
),
redact_patterns=_str_list(patterns),
)


Expand Down
69 changes: 69 additions & 0 deletions tests/test_config_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,3 +587,72 @@ def test_every_declared_list_field_wraps_a_bare_scalar(self):
f"{name} is list[{element}], which has no probe above — add one "
"rather than leaving the field unchecked"
)

def test_defaults_arrive_as_lists_not_tuples(self):
"""`from_dict({})` must hand every `list[X]` field an actual list.

The sweeps above feed values in; none of them ever checked what falls
out when a section is absent and the builder supplies its own default.
A default spelled as a module-level tuple (immutable, safe to share)
that a builder passes through unconverted reaches `coerce_scalars` as
a non-list — one "ignoring non-list config value" warning per config
load, on configs that never mention the section at all. That is how
`langfuse.redact_patterns` shipped. Tuples now normalize like any
other code-side scalar, and this sweep pins the container type of
every built default so the next tuple constant fails here instead of
warning in production.
"""
import dataclasses
import inspect
import typing

import nerve.config as cfg

two_arg = {"McpServerConfig"} # from_dict(cls, name, d)
broken, checked = [], 0
for _name, klass in sorted(vars(cfg).items()):
if not (inspect.isclass(klass) and dataclasses.is_dataclass(klass)):
continue
from_dict = getattr(klass, "from_dict", None)
if not callable(from_dict):
continue
hints = typing.get_type_hints(klass)
list_fields = [
f.name
for f in dataclasses.fields(klass)
if typing.get_origin(hints.get(f.name)) is list
]
if not list_fields:
continue
built = from_dict("probe", {}) if klass.__name__ in two_arg \
else from_dict({})
for name in list_fields:
checked += 1
got = getattr(built, name)
if not isinstance(got, list):
broken.append(
f"{klass.__name__}.{name}: from_dict({{}}) built a "
f"{type(got).__name__}: {got!r}"
)
assert checked >= 19, f"probe reached only {checked} list fields — did it break?"
assert not broken, (
"list fields whose built defaults are not lists:\n" + "\n".join(broken)
)

def test_langfuse_default_redact_patterns_load_cleanly(self, caplog):
"""An absent langfuse block keeps the default redact set, silently.

Regression: the defaults constant is a tuple, and `from_dict` used to
hand it through verbatim — the patterns still compiled (the consumer
`list()`s the field), but every single config load logged a
`nerve.coerce` warning about it.
"""
import logging

from nerve.config import _DEFAULT_LANGFUSE_REDACT_PATTERNS, LangfuseConfig

with caplog.at_level(logging.WARNING, logger="nerve.coerce"):
lf = LangfuseConfig.from_dict({})
assert lf.redact_patterns == list(_DEFAULT_LANGFUSE_REDACT_PATTERNS)
coerce_records = [r for r in caplog.records if r.name == "nerve.coerce"]
assert not coerce_records, [r.getMessage() for r in coerce_records]
Loading