Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,72 @@
# 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)
```

`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`
```

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

Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 116 additions & 1 deletion tests/test_source_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -36,6 +39,118 @@ def test_closest_entry():
assert closest_entry(sm, "/foo/1") == "idx 1"


def test_build_toml_table():
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),
)
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]]
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),
)
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
'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),
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
Loading