fix(map): reject duplicate YAML keys via a SafeLoader duplicate-check
This commit is contained in:
@@ -12,10 +12,14 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
||||
from yaml.nodes import ScalarNode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Hashable
|
||||
from pathlib import Path
|
||||
|
||||
from yaml.nodes import MappingNode
|
||||
|
||||
Pos = tuple[int, int] # (row, col), 0-indexed
|
||||
|
||||
_ELEVATION_CHARS = frozenset("0123456789.") # digit = height, '.' = ground level
|
||||
@@ -38,6 +42,29 @@ class MapValidationError(Exception):
|
||||
self.reason = reason
|
||||
|
||||
|
||||
class _NoDuplicateLoader(yaml.SafeLoader):
|
||||
"""SafeLoader that rejects duplicate mapping keys (PyYAML is last-wins)."""
|
||||
|
||||
|
||||
def _construct_mapping(
|
||||
loader: _NoDuplicateLoader, node: MappingNode, *, deep: bool = False
|
||||
) -> dict[Hashable, object]:
|
||||
seen: set[str] = set()
|
||||
for key_node, _ in node.value:
|
||||
if isinstance(key_node, ScalarNode):
|
||||
key = loader.construct_scalar(key_node)
|
||||
if key in seen:
|
||||
msg = f"duplicate mapping key: {key!r}"
|
||||
raise yaml.constructor.ConstructorError(None, None, msg, key_node.start_mark)
|
||||
seen.add(key)
|
||||
return yaml.constructor.SafeConstructor.construct_mapping(loader, node, deep)
|
||||
|
||||
|
||||
_NoDuplicateLoader.add_constructor(
|
||||
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping
|
||||
)
|
||||
|
||||
|
||||
class TerrainType(BaseModel):
|
||||
"""What one square means for movement and sight lines."""
|
||||
|
||||
@@ -177,7 +204,13 @@ def load_map(path: Path) -> MapSpec:
|
||||
msg = f"cannot read file: {exc}"
|
||||
raise MapValidationError(path, msg) from exc
|
||||
try:
|
||||
data: object = yaml.safe_load(text)
|
||||
# _NoDuplicateLoader subclasses SafeLoader: no arbitrary object
|
||||
# instantiation, it only adds duplicate-key rejection (S506 is a
|
||||
# false positive for this loader).
|
||||
data: object = yaml.load(
|
||||
text,
|
||||
Loader=_NoDuplicateLoader, # noqa: S506
|
||||
)
|
||||
except yaml.YAMLError as exc:
|
||||
msg = f"invalid YAML: {exc}"
|
||||
raise MapValidationError(path, msg) from exc
|
||||
|
||||
Reference in New Issue
Block a user