From 548b30aee4e2a713ca7f7c44ae151a6d7db2c957 Mon Sep 17 00:00:00 2001 From: Tim Hatch Date: Wed, 18 Mar 2026 10:33:19 -0700 Subject: [PATCH] [04/n] Add json_source_map This is a thin wrapper around an existing open source no-deps source map. Horray for simple-to-parse formats. --- parse_errors/json_source_map/__init__.py | 25 ++++++++ parse_errors/json_source_map/__main__.py | 7 +++ parse_errors/source_map.py | 7 +++ setup.cfg | 1 + tests/test_parse_context_json.py | 79 ++++++++++++++++++++++++ 5 files changed, 119 insertions(+) create mode 100644 parse_errors/json_source_map/__init__.py create mode 100644 parse_errors/json_source_map/__main__.py create mode 100644 tests/test_parse_context_json.py diff --git a/parse_errors/json_source_map/__init__.py b/parse_errors/json_source_map/__init__.py new file mode 100644 index 0000000..043616e --- /dev/null +++ b/parse_errors/json_source_map/__init__.py @@ -0,0 +1,25 @@ +"""Thin wrapper around the json-source-map package that returns our own types.""" + +from __future__ import annotations + +import json_source_map as _ext + +from ..source_map import Entry, Location, TSourceMap + + +def calculate(source: str) -> TSourceMap: + """Calculate the source map for a JSON document.""" + return { + pointer: Entry( + value_start=_loc(e.value_start), + value_end=_loc(e.value_end), + key_start=_loc(e.key_start) if e.key_start is not None else None, + key_end=_loc(e.key_end) if e.key_end is not None else None, + ) + for pointer, e in _ext.calculate(source).items() + } + + +def _loc(ext: _ext.Location) -> Location: + """Translate to our internal structure.""" + return Location(line=ext.line, column=ext.column, position=ext.position) diff --git a/parse_errors/json_source_map/__main__.py b/parse_errors/json_source_map/__main__.py new file mode 100644 index 0000000..9744095 --- /dev/null +++ b/parse_errors/json_source_map/__main__.py @@ -0,0 +1,7 @@ +if __name__ == "__main__": # pragma: no cover + import sys + from . import calculate + + source = open(sys.argv[1]).read() + for pointer, entry in calculate(source).items(): + print(f"{pointer!r:40s} {entry}") diff --git a/parse_errors/source_map.py b/parse_errors/source_map.py index ca51b46..3207129 100644 --- a/parse_errors/source_map.py +++ b/parse_errors/source_map.py @@ -33,6 +33,7 @@ def detect_format(path: Path) -> str | None: """Detect the format of a file based on its extension.""" suffix = path.suffix.lower() return { + ".json": "json", ".toml": "toml", ".yaml": "yaml", ".yml": "yaml", @@ -51,6 +52,12 @@ def build_source_map(source: str | bytes, fmt: str) -> TSourceMap: return yaml_source_map.calculate( source.decode("utf-8") if isinstance(source, bytes) else source ) + elif fmt == "json": + from . import json_source_map + + return json_source_map.calculate( + source.decode("utf-8") if isinstance(source, bytes) else source + ) else: raise ValueError(f"Unknown format: {fmt!r}") diff --git a/setup.cfg b/setup.cfg index fa2dafc..84fe3d6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,6 +16,7 @@ setup_requires = setuptools >= 65 include_package_data = true install_requires = + json-source-map pyyaml tree-sitter tree-sitter-toml diff --git a/tests/test_parse_context_json.py b/tests/test_parse_context_json.py new file mode 100644 index 0000000..3147af0 --- /dev/null +++ b/tests/test_parse_context_json.py @@ -0,0 +1,79 @@ +import pytest +import msgspec + +from parse_errors import ParseContext, ParseError + +from ._types import Config, Nested + + +JSON_GOOD = b'{"host": "localhost", "port": 8080}' +JSON_BAD = b'{"host": "localhost", "port": "not-an-int"}' +JSON_NESTED_BAD = b'{"server": {"host": "localhost", "port": "not-an-int"}}' + +JSON_SOURCE = """\ +{ + "host": "localhost", + "port": "not-an-int" +} +""" + +JSON_NESTED_SOURCE = """\ +{ + "server": { + "host": "localhost", + "port": "not-an-int" + } +} +""" + + +def test_json_no_error(): + with ParseContext("config.json", data=JSON_GOOD.decode()): + msgspec.json.decode(JSON_GOOD, type=Config) + + +def test_json_non_jsonpath_exception_passes_through(): + with pytest.raises(ZeroDivisionError): + with ParseContext("config.json", data=JSON_SOURCE): + raise ZeroDivisionError("oops") + + +def test_json_raises_parse_error(): + with pytest.raises(ParseError) as exc_info: + with ParseContext("config.json", data=JSON_SOURCE): + msgspec.json.decode(JSON_SOURCE.encode(), type=Config) + + err = exc_info.value + assert err.filename == "config.json" + assert err.line == 3 + assert str(err) == "config.json:3:11: Expected `int`, got `str` - at `$.port`" + + +def test_json_nested_raises_parse_error(): + with pytest.raises(ParseError) as exc_info: + with ParseContext("config.json", data=JSON_NESTED_SOURCE): + msgspec.json.decode(JSON_NESTED_SOURCE.encode(), type=Nested) + + err = exc_info.value + assert ( + str(err) == "config.json:4:13: Expected `int`, got `str` - at `$.server.port`" + ) + + +def test_json_bytes_data(): + with pytest.raises(ParseError) as exc_info: + with ParseContext("config.json", data=JSON_SOURCE.encode()): + msgspec.json.decode(JSON_SOURCE.encode(), type=Config) + + assert ( + str(exc_info.value) + == "config.json:3:11: Expected `int`, got `str` - at `$.port`" + ) + + +def test_json_original_exception_is_cause(): + with pytest.raises(ParseError) as exc_info: + with ParseContext("config.json", data=JSON_SOURCE): + msgspec.json.decode(JSON_SOURCE.encode(), type=Config) + + assert isinstance(exc_info.value.__cause__, msgspec.ValidationError)