From 37105c3cfa749d085ef10cf0d0567c3b004c1072 Mon Sep 17 00:00:00 2001 From: Tim Hatch Date: Mon, 30 Mar 2026 20:39:31 -0700 Subject: [PATCH 1/2] Write readme, increase test coverage --- README.md | 62 ++++++++++++++++++++++++ setup.cfg | 2 +- tests/test_source_map.py | 101 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ef428e5..b014607 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,67 @@ # parse-errors +`parse-errors` improves the errors you get when parsing config files (JSON, +TOML, YAML). Instead of a bare exception with a vague message, you get a +`ParseError` that includes the filename, line number, and column — so you can +point users straight to the problem. + +It understands msgspec validation errors and TOML syntax errors, mapping them +back to the line of config they came from. Even unrelated exceptions get the +filename attached. + +## Usage + +Wrap your parse/validate call in `ParseContext`: + +```python +import msgspec +import tomllib +from parse_errors import ParseContext + +class Config(msgspec.Struct): + host: str + port: int + +filename = "config.toml" + +with open(filename, "rb") as f: + raw = f.read() + +with ParseContext(filename, data=raw, format="toml"): + data = tomllib.loads(raw.decode()) + config = msgspec.convert(data, Config) +``` + +If `msgspec.convert` raises because `port` is a string instead of an integer, +you get something like: + +``` +parse_errors.ParseError: config.toml:3:8: Expected `int`, got `str` - at `$.port` +``` + +rather than the bare `msgspec.ValidationError` with no location. + +`ParseContext` also handles errors from TOML and other decoders that include +positional information (`at line N, column M`) and re-raises them in the same +`filename:line:col: message` format. + +## API + +```python +from parse_errors import ParseContext, ParseError +``` + +**`ParseContext(filename, *, data=None, format=None)`** — context manager. + +- `filename`: path to the file being parsed (used in error messages and to + infer the format from the extension when `format` is omitted). +- `data`: the file contents as `str` or `bytes`. If omitted, the file is read + from disk automatically when location info is needed. +- `format`: `"json"`, `"toml"`, or `"yaml"`. Inferred from `filename`'s + extension when not supplied. + +**`ParseError`** — the exception raised inside the context. Has attributes +`filename`, `line` (1-based), and `column` (1-based). # Version Compat diff --git a/setup.cfg b/setup.cfg index 84fe3d6..01f55cb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -47,7 +47,7 @@ branch = True source = parse_errors,tests [coverage:report] -fail_under = 80 +fail_under = 90 precision = 1 show_missing = True skip_covered = True diff --git a/tests/test_source_map.py b/tests/test_source_map.py index e0beb75..1dd8123 100644 --- a/tests/test_source_map.py +++ b/tests/test_source_map.py @@ -36,6 +36,107 @@ def test_closest_entry(): assert closest_entry(sm, "/foo/1") == "idx 1" +def test_build_toml_table(): + sm = build_source_map("[section]\nkey = \"val\"\n", fmt="toml") + assert sm[""] == Entry( + value_start=Location(line=0, column=0, position=0), + value_end=Location(line=2, column=0, position=22), + ) + assert sm["/section"] == Entry( + value_start=Location(line=0, column=0, position=0), + value_end=Location(line=2, column=0, position=22), + ) + assert sm["/section/key"] == Entry( + value_start=Location(line=1, column=6, position=16), + value_end=Location(line=1, column=11, position=21), + key_start=Location(line=1, column=0, position=10), + key_end=Location(line=1, column=3, position=13), + ) + + +def test_build_toml_aot(): + sm = build_source_map("[[items]]\nname = \"a\"\n[[items]]\nname = \"b\"\n", fmt="toml") + assert sm["/items"] == Entry( + value_start=Location(line=0, column=0, position=0), + value_end=Location(line=0, column=0, position=0), + ) + assert sm["/items/0"] == Entry( + value_start=Location(line=0, column=0, position=0), + value_end=Location(line=2, column=0, position=21), + ) + assert sm["/items/0/name"] == Entry( + value_start=Location(line=1, column=7, position=17), + value_end=Location(line=1, column=10, position=20), + key_start=Location(line=1, column=0, position=10), + key_end=Location(line=1, column=4, position=14), + ) + assert sm["/items/1"] == Entry( + value_start=Location(line=2, column=0, position=21), + value_end=Location(line=4, column=0, position=42), + ) + assert sm["/items/1/name"] == Entry( + value_start=Location(line=3, column=7, position=38), + value_end=Location(line=3, column=10, position=41), + key_start=Location(line=3, column=0, position=31), + key_end=Location(line=3, column=4, position=35), + ) + + +def test_build_toml_inline_table(): + sm = build_source_map("x = {a = 1}\n", fmt="toml") + assert sm["/x"] == Entry( + value_start=Location(line=0, column=4, position=4), + value_end=Location(line=0, column=11, position=11), + key_start=Location(line=0, column=0, position=0), + key_end=Location(line=0, column=1, position=1), + ) + assert sm["/x/a"] == Entry( + value_start=Location(line=0, column=9, position=9), + value_end=Location(line=0, column=10, position=10), + key_start=Location(line=0, column=5, position=5), + key_end=Location(line=0, column=6, position=6), + ) + + +def test_build_toml_dotted_key(): + sm = build_source_map("a.b = 1\n", fmt="toml") + assert sm["/a/b"] == Entry( + value_start=Location(line=0, column=6, position=6), + value_end=Location(line=0, column=7, position=7), + key_start=Location(line=0, column=0, position=0), + key_end=Location(line=0, column=3, position=3), + ) + + +def test_build_toml_quoted_keys(): + sm = build_source_map('"foo" = 1\n\'bar\' = 2\n', fmt="toml") + assert sm["/foo"] == Entry( + value_start=Location(line=0, column=8, position=8), + value_end=Location(line=0, column=9, position=9), + key_start=Location(line=0, column=0, position=0), + key_end=Location(line=0, column=5, position=5), + ) + assert sm["/bar"] == Entry( + value_start=Location(line=1, column=8, position=18), + value_end=Location(line=1, column=9, position=19), + key_start=Location(line=1, column=0, position=10), + key_end=Location(line=1, column=5, position=15), + ) + + +def test_build_toml_aot_nested_table(): + sm = build_source_map( + "[[fruits]]\nname=\"apple\"\n[fruits.details]\ncolor=\"red\"\n", fmt="toml" + ) + assert "/fruits/0/details" in sm + assert sm["/fruits/0/details/color"] == Entry( + value_start=Location(line=3, column=6, position=47), + value_end=Location(line=3, column=11, position=52), + key_start=Location(line=3, column=0, position=41), + key_end=Location(line=3, column=5, position=46), + ) + + def test_closest_entry_fallthrough(): sm = {} assert closest_entry(sm, "/baz") is None From 9eb13d5d6f75e7bbf14871fbe0599068f90ecb78 Mon Sep 17 00:00:00 2001 From: Tim Hatch Date: Thu, 9 Apr 2026 12:46:50 -0700 Subject: [PATCH 2/2] Apply readme/test suggestions --- README.md | 9 +++++++-- tests/test_source_map.py | 22 ++++++++++++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b014607..beb030d 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,13 @@ with ParseContext(filename, data=raw, format="toml"): config = msgspec.convert(data, Config) ``` -If `msgspec.convert` raises because `port` is a string instead of an integer, -you get something like: +`ParseContext` will intercept exceptions. If it can analyze them for precise +locations, it will raise a `ParseError` exception with the original exception as +the cause. If it can't find location information, the original exception is +raised as-is. No exceptions are swallowed. + +As a concrete example, if the `msgspec.convert` raises because `port` is a +string instead of an integer, you get something like: ``` parse_errors.ParseError: config.toml:3:8: Expected `int`, got `str` - at `$.port` diff --git a/tests/test_source_map.py b/tests/test_source_map.py index 1dd8123..8cf9ab4 100644 --- a/tests/test_source_map.py +++ b/tests/test_source_map.py @@ -4,7 +4,10 @@ def test_build_toml(): # This isn't an exhaustive test of the toml source mapper, just as something # a minimal example that lets us exercise str/bytes - sm1 = build_source_map("x=1\nb='foo'\n", fmt="toml") + sm1 = build_source_map("""\ +x=1 +b='foo' +""", fmt="toml") assert sm1 == { "": Entry( value_start=Location(line=0, column=0, position=0), @@ -37,7 +40,10 @@ def test_closest_entry(): def test_build_toml_table(): - sm = build_source_map("[section]\nkey = \"val\"\n", fmt="toml") + sm = build_source_map("""\ +[section] +key = "val" +""", fmt="toml") assert sm[""] == Entry( value_start=Location(line=0, column=0, position=0), value_end=Location(line=2, column=0, position=22), @@ -55,7 +61,12 @@ def test_build_toml_table(): def test_build_toml_aot(): - sm = build_source_map("[[items]]\nname = \"a\"\n[[items]]\nname = \"b\"\n", fmt="toml") + sm = build_source_map("""\ +[[items]] +name = "a" +[[items]] +name = "b" +""", fmt="toml") assert sm["/items"] == Entry( value_start=Location(line=0, column=0, position=0), value_end=Location(line=0, column=0, position=0), @@ -109,7 +120,10 @@ def test_build_toml_dotted_key(): def test_build_toml_quoted_keys(): - sm = build_source_map('"foo" = 1\n\'bar\' = 2\n', fmt="toml") + sm = build_source_map('''\ +"foo" = 1 +'bar' = 2 +''', fmt="toml") assert sm["/foo"] == Entry( value_start=Location(line=0, column=8, position=8), value_end=Location(line=0, column=9, position=9),