Skip to content

Commit 1362a75

Browse files
feat(config): add shared _parse_headers helper for declarative config exporters (#5021)
* add shared _parse_headers helper for declarative config exporters Extracts the header-merging logic that is duplicated across the tracer, meter, and logger provider config modules into a single shared helper in _common.py. Subsequent provider PRs will import from here instead of defining their own copy. Assisted-by: Claude Sonnet 4.6 * add changelog entry for #5021 Assisted-by: Claude Sonnet 4.6
1 parent db134a3 commit 1362a75

File tree

3 files changed

+132
-0
lines changed

3 files changed

+132
-0
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212

1313
## Unreleased
1414

15+
- `opentelemetry-sdk`: Add shared `_parse_headers` helper for declarative config OTLP exporters
16+
([#5021](https://github.com/open-telemetry/opentelemetry-python/pull/5021))
1517
- `opentelemetry-api`: Replace a broad exception in attribute cleaning tests to satisfy pylint in the `lint-opentelemetry-api` CI job
1618
- `opentelemetry-sdk`: Add `create_resource` and `create_propagator`/`configure_propagator` to declarative file configuration, enabling Resource and propagator instantiation from config files without reading env vars
1719
([#4979](https://github.com/open-telemetry/opentelemetry-python/pull/4979))
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
import logging
18+
from typing import Optional
19+
20+
_logger = logging.getLogger(__name__)
21+
22+
23+
def _parse_headers(
24+
headers: Optional[list],
25+
headers_list: Optional[str],
26+
) -> Optional[dict[str, str]]:
27+
"""Merge headers struct and headers_list into a dict.
28+
29+
Returns None if neither is set, letting the exporter read env vars.
30+
headers struct takes priority over headers_list for the same key.
31+
"""
32+
if headers is None and headers_list is None:
33+
return None
34+
result: dict[str, str] = {}
35+
if headers_list:
36+
for item in headers_list.split(","):
37+
item = item.strip()
38+
if "=" in item:
39+
key, value = item.split("=", 1)
40+
result[key.strip()] = value.strip()
41+
elif item:
42+
_logger.warning(
43+
"Invalid header pair in headers_list (missing '='): %s",
44+
item,
45+
)
46+
if headers:
47+
for pair in headers:
48+
result[pair.name] = pair.value or ""
49+
return result
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import unittest
16+
from types import SimpleNamespace
17+
18+
from opentelemetry.sdk._configuration._common import _parse_headers
19+
20+
21+
class TestParseHeaders(unittest.TestCase):
22+
def test_both_none_returns_none(self):
23+
self.assertIsNone(_parse_headers(None, None))
24+
25+
def test_empty_headers_list_returns_empty_dict(self):
26+
self.assertEqual(_parse_headers(None, ""), {})
27+
28+
def test_headers_list_single_pair(self):
29+
self.assertEqual(
30+
_parse_headers(None, "x-api-key=secret"),
31+
{"x-api-key": "secret"},
32+
)
33+
34+
def test_headers_list_multiple_pairs(self):
35+
self.assertEqual(
36+
_parse_headers(None, "x-api-key=secret,env=prod"),
37+
{"x-api-key": "secret", "env": "prod"},
38+
)
39+
40+
def test_headers_list_strips_whitespace(self):
41+
self.assertEqual(
42+
_parse_headers(None, " x-api-key = secret , env = prod "),
43+
{"x-api-key": "secret", "env": "prod"},
44+
)
45+
46+
def test_headers_list_value_with_equals(self):
47+
# value contains '=' — only split on the first one
48+
self.assertEqual(
49+
_parse_headers(None, "auth=Bearer tok=en"),
50+
{"auth": "Bearer tok=en"},
51+
)
52+
53+
def test_headers_list_invalid_pair_ignored(self):
54+
# malformed entry (no '=') should be skipped with a warning
55+
result = _parse_headers(None, "bad,x-key=val")
56+
self.assertEqual(result, {"x-key": "val"})
57+
58+
def test_struct_headers_only(self):
59+
headers = [
60+
SimpleNamespace(name="x-api-key", value="secret"),
61+
SimpleNamespace(name="env", value="prod"),
62+
]
63+
self.assertEqual(
64+
_parse_headers(headers, None),
65+
{"x-api-key": "secret", "env": "prod"},
66+
)
67+
68+
def test_struct_header_none_value_becomes_empty_string(self):
69+
headers = [SimpleNamespace(name="x-key", value=None)]
70+
self.assertEqual(_parse_headers(headers, None), {"x-key": ""})
71+
72+
def test_struct_headers_override_headers_list(self):
73+
# struct takes priority over headers_list for same key
74+
headers = [SimpleNamespace(name="x-api-key", value="from-struct")]
75+
self.assertEqual(
76+
_parse_headers(headers, "x-api-key=from-list,env=prod"),
77+
{"x-api-key": "from-struct", "env": "prod"},
78+
)
79+
80+
def test_both_empty_struct_and_none_list_returns_empty_dict(self):
81+
self.assertEqual(_parse_headers([], None), {})

0 commit comments

Comments
 (0)