# SPDX-License-Identifier: BUSL-1.1 # Copyright (c) 2024-2026 Ali Sadeghi Aghili """YAML loading for operator-edited configuration files. ``yaml.safe_load`` keeps the last of two identical keys in one mapping and says nothing. In a config file that silently discards whatever the first block described -- an operator who pasted a second ``datasources:`` heading lost a whole data source with no error. :func:`safe_load_strict` is ``yaml.safe_load`` that refuses such a file instead. Public API ---------- safe_load_strict(stream) -> Any Every repeated key in the document is reported in one error, not just the first: a schema draft with seventy repeated table names should cost the operator one run to learn about, not seventy. """ from __future__ import annotations from collections.abc import Hashable from typing import IO, Any import yaml from yaml.constructor import ConstructorError __all__ = ["safe_load_strict"] _MERGE_TAG = "tag:yaml.org,2002:merge" #: Duplicate keys listed in a multi-key error before "... and N more". _MAX_LISTED_DUPLICATES = 20 class _StrictLoader(yaml.SafeLoader): """``SafeLoader`` that records every repeated explicit key in a mapping. Construction is never interrupted by a duplicate: the loader collects them all and :meth:`raise_for_duplicates` reports the lot once the whole document has been read. """ def __init__(self, stream: Any) -> None: super().__init__(stream) # Nodes already checked. PyYAML's flatten_mapping rewrites a merge # target's node in place, so a node reached a second time already # holds its merged keys and an explicit override of one would look # like a duplicate. self._checked: set[yaml.Node] = set() # (key, 1-based lines of every occurrence), one entry per repeated # key per mapping, in the order PyYAML reached the mappings. self._duplicates: list[tuple[Any, list[int]]] = [] def _note_duplicate_keys(self, node: yaml.Node) -> None: if not isinstance(node, yaml.MappingNode) or node in self._checked: return self._checked.add(node) lines: dict[Any, list[int]] = {} for key_node, _ in node.value: if key_node.tag == _MERGE_TAG: continue key = self.construct_object(key_node, deep=True) if not isinstance(key, Hashable): continue # PyYAML raises its own "unhashable key" error lines.setdefault(key, []).append(key_node.start_mark.line + 1) self._duplicates.extend( (key, found) for key, found in lines.items() if len(found) > 1 ) def flatten_mapping(self, node: yaml.MappingNode) -> None: # Reached for merge targets too, which construct_mapping never sees. self._note_duplicate_keys(node) super().flatten_mapping(node) def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict: # Before flattening: afterwards merged keys sit among the node's own. self._note_duplicate_keys(node) return super().construct_mapping(node, deep=deep) def raise_for_duplicates(self) -> None: """Raise one ``ConstructorError`` naming every duplicate key found. No marks are attached: a mark renders as a multi-line snippet that names "" rather than the file, and the lines are already in the message. """ if not self._duplicates: return # File order: PyYAML builds shallow mappings before deep ones, so # the order they were reached in is not the order they are written. found = sorted(self._duplicates, key=lambda item: (item[1][0], repr(item[0]))) if len(found) == 1: key, lines = found[0] again = ( f"line {lines[1]}" if len(lines) == 2 else "lines " + ", ".join(map(str, lines[1:])) ) raise ConstructorError( problem=f"duplicate key {key!r} (first on line {lines[0]}, again on {again})" ) listed = [ f"{key!r} (lines {', '.join(map(str, lines))})" for key, lines in found[:_MAX_LISTED_DUPLICATES] ] if len(found) > _MAX_LISTED_DUPLICATES: listed.append(f"... and {len(found) - _MAX_LISTED_DUPLICATES} more") raise ConstructorError( problem=f"{len(found)} duplicate keys: " + "; ".join(listed) ) def safe_load_strict(stream: str | bytes | IO[str] | IO[bytes]) -> Any: """Parse one YAML document like ``yaml.safe_load``, refusing duplicate keys. Only keys written out explicitly in the same mapping count. A key that overrides one pulled in by a merge key (``<<: *anchor``) is legitimate, and so is the same key in sibling mappings or in list items. Parameters ---------- stream: YAML text, or an open file, exactly as ``yaml.safe_load`` accepts. Returns ------- Any The parsed document; ``None`` for an empty one. Raises ------ yaml.YAMLError For anything ``yaml.safe_load`` refuses. Repeated keys raise :class:`yaml.constructor.ConstructorError`, after the whole document has been read so that every one is reported at once, at every mapping level. A single repeated key is named with both 1-based line numbers; several give ``N duplicate keys:`` followed by each key and all its lines, in file order, at most 20 and then ``... and M more``. Examples -------- >>> safe_load_strict("a: 1\\nb: {c: 2}\\n") {'a': 1, 'b': {'c': 2}} >>> safe_load_strict("base: &b {x: 1, y: 2}\\nuse: {<<: *b, x: 9}\\n")["use"] {'x': 9, 'y': 2} >>> safe_load_strict("datasources:\\n a: {}\\ndatasources:\\n b: {}\\n") Traceback (most recent call last): ... yaml.constructor.ConstructorError: duplicate key 'datasources' (first on line 1, again on line 3) >>> safe_load_strict("a: 1\\nb: 1\\na: 2\\nb: 2\\n") Traceback (most recent call last): ... yaml.constructor.ConstructorError: 2 duplicate keys: 'a' (lines 1, 3); 'b' (lines 2, 4) """ # SafeLoader subclass, so it builds the same plain types safe_load does. loader = _StrictLoader(stream) try: data = loader.get_single_data() finally: loader.dispose() loader.raise_for_duplicates() return data