parse-errors 0.5.0__tar.gz → 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {parse_errors-0.5.0 → parse_errors-0.6.0}/.github/workflows/build.yml +5 -7
- parse_errors-0.6.0/MANIFEST.in +1 -0
- parse_errors-0.6.0/Makefile +22 -0
- parse_errors-0.6.0/PKG-INFO +143 -0
- parse_errors-0.6.0/README.md +108 -0
- {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors/_jsonpath.py +3 -2
- parse_errors-0.6.0/parse_errors/_version.py +24 -0
- {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors/context.py +47 -16
- parse_errors-0.6.0/parse_errors/json_source_map/__init__.py +222 -0
- {parse_errors-0.5.0/parse_errors/yaml_source_map → parse_errors-0.6.0/parse_errors/json_source_map}/__main__.py +1 -0
- parse_errors-0.6.0/parse_errors/source_map.py +155 -0
- parse_errors-0.6.0/parse_errors/toml_source_map/__init__.py +294 -0
- {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors/toml_source_map/__main__.py +1 -0
- parse_errors-0.6.0/parse_errors/yaml_source_map/__init__.py +160 -0
- {parse_errors-0.5.0/parse_errors/json_source_map → parse_errors-0.6.0/parse_errors/yaml_source_map}/__main__.py +1 -0
- parse_errors-0.6.0/parse_errors.egg-info/PKG-INFO +143 -0
- {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors.egg-info/SOURCES.txt +3 -2
- {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors.egg-info/requires.txt +8 -6
- parse_errors-0.6.0/parse_errors.egg-info/scm_file_list.json +30 -0
- parse_errors-0.6.0/parse_errors.egg-info/scm_version.json +8 -0
- {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors.egg-info/top_level.txt +0 -1
- parse_errors-0.6.0/pyproject.toml +105 -0
- parse_errors-0.6.0/setup.cfg +4 -0
- {parse_errors-0.5.0 → parse_errors-0.6.0}/tests/test_parse_context_json.py +31 -3
- {parse_errors-0.5.0 → parse_errors-0.6.0}/tests/test_parse_context_toml.py +20 -6
- parse_errors-0.6.0/tests/test_source_map.py +327 -0
- parse_errors-0.5.0/MANIFEST.in +0 -2
- parse_errors-0.5.0/Makefile +0 -37
- parse_errors-0.5.0/PKG-INFO +0 -46
- parse_errors-0.5.0/README.md +0 -18
- parse_errors-0.5.0/parse_errors/_version.py +0 -34
- parse_errors-0.5.0/parse_errors/json_source_map/__init__.py +0 -25
- parse_errors-0.5.0/parse_errors/source_map.py +0 -77
- parse_errors-0.5.0/parse_errors/toml_source_map/__init__.py +0 -194
- parse_errors-0.5.0/parse_errors/yaml_source_map/__init__.py +0 -62
- parse_errors-0.5.0/parse_errors.egg-info/PKG-INFO +0 -46
- parse_errors-0.5.0/setup.cfg +0 -79
- parse_errors-0.5.0/setup.py +0 -3
- parse_errors-0.5.0/tests/test_source_map.py +0 -41
- {parse_errors-0.5.0 → parse_errors-0.6.0}/.gitignore +0 -0
- {parse_errors-0.5.0 → parse_errors-0.6.0}/.vars.ini +0 -0
- {parse_errors-0.5.0 → parse_errors-0.6.0}/LICENSE +0 -0
- {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors/__init__.py +0 -0
- {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors/py.typed +0 -0
- {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors.egg-info/dependency_links.txt +0 -0
- {parse_errors-0.5.0 → parse_errors-0.6.0}/tests/__init__.py +0 -0
- {parse_errors-0.5.0 → parse_errors-0.6.0}/tests/_types.py +0 -0
- {parse_errors-0.5.0 → parse_errors-0.6.0}/tests/conftest.py +0 -0
- {parse_errors-0.5.0 → parse_errors-0.6.0}/tests/test_parse_context_yaml.py +1 -1
|
@@ -9,9 +9,6 @@ on:
|
|
|
9
9
|
- v*
|
|
10
10
|
pull_request:
|
|
11
11
|
|
|
12
|
-
env:
|
|
13
|
-
UV_SYSTEM_PYTHON: 1
|
|
14
|
-
|
|
15
12
|
jobs:
|
|
16
13
|
test:
|
|
17
14
|
runs-on: ${{ matrix.os }}
|
|
@@ -31,8 +28,7 @@ jobs:
|
|
|
31
28
|
allow-prereleases: true
|
|
32
29
|
- uses: astral-sh/setup-uv@v3
|
|
33
30
|
- name: Install
|
|
34
|
-
run:
|
|
35
|
-
uv pip install -e .[test,dev]
|
|
31
|
+
run: uv sync --extra test --extra dev
|
|
36
32
|
- name: Test
|
|
37
33
|
run: |
|
|
38
34
|
git config --global user.name "Unit Test"
|
|
@@ -52,9 +48,9 @@ jobs:
|
|
|
52
48
|
python-version: "3.14"
|
|
53
49
|
- uses: astral-sh/setup-uv@v3
|
|
54
50
|
- name: Install
|
|
55
|
-
run: uv
|
|
51
|
+
run: uv sync --extra test --extra dev
|
|
56
52
|
- name: Build
|
|
57
|
-
run: python -m build
|
|
53
|
+
run: uv run python -m build
|
|
58
54
|
- name: Upload
|
|
59
55
|
uses: actions/upload-artifact@v4
|
|
60
56
|
with:
|
|
@@ -65,7 +61,9 @@ jobs:
|
|
|
65
61
|
needs: build
|
|
66
62
|
runs-on: ubuntu-latest
|
|
67
63
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
64
|
+
environment: pypi
|
|
68
65
|
permissions:
|
|
66
|
+
contents: read
|
|
69
67
|
id-token: write
|
|
70
68
|
steps:
|
|
71
69
|
- uses: actions/download-artifact@v4
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
include *.md LICENSE
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
.venv:
|
|
2
|
+
uv sync --extra dev --extra test
|
|
3
|
+
|
|
4
|
+
.PHONY: setup
|
|
5
|
+
setup:
|
|
6
|
+
uv sync --extra dev --extra test
|
|
7
|
+
|
|
8
|
+
.PHONY: test
|
|
9
|
+
test:
|
|
10
|
+
uv run coverage run -m pytest $(TESTOPTS)
|
|
11
|
+
uv run coverage report
|
|
12
|
+
|
|
13
|
+
.PHONY: format
|
|
14
|
+
format:
|
|
15
|
+
uv run ruff format
|
|
16
|
+
uv run ruff check --fix
|
|
17
|
+
|
|
18
|
+
.PHONY: lint
|
|
19
|
+
lint:
|
|
20
|
+
uv run ruff check
|
|
21
|
+
uv run python -m checkdeps --allow-names parse_errors parse_errors
|
|
22
|
+
uv run mypy --strict --install-types --non-interactive parse_errors
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parse-errors
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: re-raise parse errors with filename and line number
|
|
5
|
+
Author-email: Tim Hatch <tim@timhatch.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/advice-animal/parse-errors/
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: pyyaml
|
|
18
|
+
Requires-Dist: tree-sitter!=0.26.0
|
|
19
|
+
Requires-Dist: tree-sitter-json
|
|
20
|
+
Requires-Dist: tree-sitter-toml
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build>=1; extra == "dev"
|
|
23
|
+
Requires-Dist: checkdeps==0.9.0; extra == "dev"
|
|
24
|
+
Requires-Dist: mypy==1.19.1; extra == "dev"
|
|
25
|
+
Requires-Dist: ruff==0.15.6; extra == "dev"
|
|
26
|
+
Requires-Dist: setuptools>=65; extra == "dev"
|
|
27
|
+
Requires-Dist: ty==0.0.81; extra == "dev"
|
|
28
|
+
Requires-Dist: types-pyyaml; extra == "dev"
|
|
29
|
+
Provides-Extra: test
|
|
30
|
+
Requires-Dist: coverage>=7.14; extra == "test"
|
|
31
|
+
Requires-Dist: msgspec; extra == "test"
|
|
32
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
33
|
+
Requires-Dist: tomli; extra == "test"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# parse-errors
|
|
37
|
+
|
|
38
|
+
`parse-errors` improves the errors you get when parsing config files (JSON,
|
|
39
|
+
TOML, YAML). Instead of a bare exception with a vague message, you get a
|
|
40
|
+
`ParseError` that includes the filename, line number, and column — so you can
|
|
41
|
+
point users straight to the problem.
|
|
42
|
+
|
|
43
|
+
It understands msgspec validation errors and TOML syntax errors, mapping them
|
|
44
|
+
back to the line of config they came from. Even unrelated exceptions get the
|
|
45
|
+
filename attached.
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
Wrap your parse/validate call in `ParseContext`:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import msgspec
|
|
53
|
+
import tomllib
|
|
54
|
+
from parse_errors import ParseContext
|
|
55
|
+
|
|
56
|
+
class Config(msgspec.Struct):
|
|
57
|
+
host: str
|
|
58
|
+
port: int
|
|
59
|
+
|
|
60
|
+
filename = "config.toml"
|
|
61
|
+
|
|
62
|
+
with open(filename, "rb") as f:
|
|
63
|
+
raw = f.read()
|
|
64
|
+
|
|
65
|
+
with ParseContext(filename, data=raw, format="toml"):
|
|
66
|
+
data = tomllib.loads(raw.decode())
|
|
67
|
+
config = msgspec.convert(data, Config)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`ParseContext` will intercept exceptions. If it can analyze them for precise
|
|
71
|
+
locations, it will raise a `ParseError` exception with the original exception as
|
|
72
|
+
the cause. If it can't find location information, the original exception is
|
|
73
|
+
raised as-is. No exceptions are swallowed.
|
|
74
|
+
|
|
75
|
+
As a concrete example, if the `msgspec.convert` raises because `port` is a
|
|
76
|
+
string instead of an integer, you get something like:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
parse_errors.ParseError: config.toml:3:8: Expected `int`, got `str` - at `$.port`
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
rather than the bare `msgspec.ValidationError` with no location.
|
|
83
|
+
|
|
84
|
+
`ParseContext` also handles errors from TOML and other decoders that include
|
|
85
|
+
positional information (`at line N, column M`) and re-raises them in the same
|
|
86
|
+
`filename:line:col: message` format.
|
|
87
|
+
|
|
88
|
+
## API
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from parse_errors import ParseContext, ParseError
|
|
92
|
+
from parse_errors.source_map import SourceMap, build_source_map, locate_pointer
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**`ParseContext(filename, *, data=None, format=None)`** — context manager.
|
|
96
|
+
|
|
97
|
+
- `filename`: path to the file being parsed (used in error messages and to
|
|
98
|
+
infer the format from the extension when `format` is omitted).
|
|
99
|
+
- `data`: the file contents as `str` or `bytes`. If omitted, the file is read
|
|
100
|
+
from disk automatically when location info is needed.
|
|
101
|
+
- `format`: `"json"`, `"toml"`, or `"yaml"`. Inferred from `filename`'s
|
|
102
|
+
extension when not supplied.
|
|
103
|
+
|
|
104
|
+
**`ParseError`** — the exception raised inside the context. Has attributes
|
|
105
|
+
`filename`, `line` (1-based), and `column` (1-based).
|
|
106
|
+
|
|
107
|
+
**`locate_pointer(source, fmt, pointer)`** — returns the best source-map entry
|
|
108
|
+
for one JSON Pointer without building a full map. `fmt` is `"json"`,
|
|
109
|
+
`"toml"`, or `"yaml"`; `source` is `str` or UTF-8 `bytes`; `pointer` uses RFC
|
|
110
|
+
6901 escaping. If the exact pointer is not present, the result matches
|
|
111
|
+
`closest_entry(build_source_map(source, fmt), pointer)`: the nearest enclosing
|
|
112
|
+
value when one exists, otherwise `None`.
|
|
113
|
+
|
|
114
|
+
**`SourceMap(source, fmt)`** — caches the parsed document for repeated targeted
|
|
115
|
+
lookups. Use `SourceMap(...).locate(pointer)` when several errors in the same
|
|
116
|
+
document need locations. It still avoids constructing a full pointer-to-entry
|
|
117
|
+
map.
|
|
118
|
+
|
|
119
|
+
**`build_source_map(source, fmt)`** — builds the full pointer-to-location map.
|
|
120
|
+
This is useful when callers need many arbitrary entries or need to inspect all
|
|
121
|
+
locations.
|
|
122
|
+
|
|
123
|
+
**Warning:** source-map helpers locate nodes in a document; they are not
|
|
124
|
+
validating parsers. JSON and TOML location support uses tree-sitter so it can
|
|
125
|
+
return a location from a syntax tree even when a real decoder would reject the
|
|
126
|
+
source. Parse or validate the document with your normal parser first, then use
|
|
127
|
+
these helpers only to map known error paths back to source locations.
|
|
128
|
+
|
|
129
|
+
# Version Compat
|
|
130
|
+
|
|
131
|
+
This library is compatible with Python 3.10+, but should be linted under the
|
|
132
|
+
newest stable version.
|
|
133
|
+
|
|
134
|
+
# Versioning
|
|
135
|
+
|
|
136
|
+
This library follows [meanver](https://meanver.org/) which basically means
|
|
137
|
+
[semver](https://semver.org/) along with a promise to rename when the major
|
|
138
|
+
version changes.
|
|
139
|
+
|
|
140
|
+
# License
|
|
141
|
+
|
|
142
|
+
parse-errors is copyright [Tim Hatch](https://timhatch.com/), and licensed under
|
|
143
|
+
the MIT license. See the `LICENSE` file for details.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# parse-errors
|
|
2
|
+
|
|
3
|
+
`parse-errors` improves the errors you get when parsing config files (JSON,
|
|
4
|
+
TOML, YAML). Instead of a bare exception with a vague message, you get a
|
|
5
|
+
`ParseError` that includes the filename, line number, and column — so you can
|
|
6
|
+
point users straight to the problem.
|
|
7
|
+
|
|
8
|
+
It understands msgspec validation errors and TOML syntax errors, mapping them
|
|
9
|
+
back to the line of config they came from. Even unrelated exceptions get the
|
|
10
|
+
filename attached.
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
Wrap your parse/validate call in `ParseContext`:
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import msgspec
|
|
18
|
+
import tomllib
|
|
19
|
+
from parse_errors import ParseContext
|
|
20
|
+
|
|
21
|
+
class Config(msgspec.Struct):
|
|
22
|
+
host: str
|
|
23
|
+
port: int
|
|
24
|
+
|
|
25
|
+
filename = "config.toml"
|
|
26
|
+
|
|
27
|
+
with open(filename, "rb") as f:
|
|
28
|
+
raw = f.read()
|
|
29
|
+
|
|
30
|
+
with ParseContext(filename, data=raw, format="toml"):
|
|
31
|
+
data = tomllib.loads(raw.decode())
|
|
32
|
+
config = msgspec.convert(data, Config)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`ParseContext` will intercept exceptions. If it can analyze them for precise
|
|
36
|
+
locations, it will raise a `ParseError` exception with the original exception as
|
|
37
|
+
the cause. If it can't find location information, the original exception is
|
|
38
|
+
raised as-is. No exceptions are swallowed.
|
|
39
|
+
|
|
40
|
+
As a concrete example, if the `msgspec.convert` raises because `port` is a
|
|
41
|
+
string instead of an integer, you get something like:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
parse_errors.ParseError: config.toml:3:8: Expected `int`, got `str` - at `$.port`
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
rather than the bare `msgspec.ValidationError` with no location.
|
|
48
|
+
|
|
49
|
+
`ParseContext` also handles errors from TOML and other decoders that include
|
|
50
|
+
positional information (`at line N, column M`) and re-raises them in the same
|
|
51
|
+
`filename:line:col: message` format.
|
|
52
|
+
|
|
53
|
+
## API
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from parse_errors import ParseContext, ParseError
|
|
57
|
+
from parse_errors.source_map import SourceMap, build_source_map, locate_pointer
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**`ParseContext(filename, *, data=None, format=None)`** — context manager.
|
|
61
|
+
|
|
62
|
+
- `filename`: path to the file being parsed (used in error messages and to
|
|
63
|
+
infer the format from the extension when `format` is omitted).
|
|
64
|
+
- `data`: the file contents as `str` or `bytes`. If omitted, the file is read
|
|
65
|
+
from disk automatically when location info is needed.
|
|
66
|
+
- `format`: `"json"`, `"toml"`, or `"yaml"`. Inferred from `filename`'s
|
|
67
|
+
extension when not supplied.
|
|
68
|
+
|
|
69
|
+
**`ParseError`** — the exception raised inside the context. Has attributes
|
|
70
|
+
`filename`, `line` (1-based), and `column` (1-based).
|
|
71
|
+
|
|
72
|
+
**`locate_pointer(source, fmt, pointer)`** — returns the best source-map entry
|
|
73
|
+
for one JSON Pointer without building a full map. `fmt` is `"json"`,
|
|
74
|
+
`"toml"`, or `"yaml"`; `source` is `str` or UTF-8 `bytes`; `pointer` uses RFC
|
|
75
|
+
6901 escaping. If the exact pointer is not present, the result matches
|
|
76
|
+
`closest_entry(build_source_map(source, fmt), pointer)`: the nearest enclosing
|
|
77
|
+
value when one exists, otherwise `None`.
|
|
78
|
+
|
|
79
|
+
**`SourceMap(source, fmt)`** — caches the parsed document for repeated targeted
|
|
80
|
+
lookups. Use `SourceMap(...).locate(pointer)` when several errors in the same
|
|
81
|
+
document need locations. It still avoids constructing a full pointer-to-entry
|
|
82
|
+
map.
|
|
83
|
+
|
|
84
|
+
**`build_source_map(source, fmt)`** — builds the full pointer-to-location map.
|
|
85
|
+
This is useful when callers need many arbitrary entries or need to inspect all
|
|
86
|
+
locations.
|
|
87
|
+
|
|
88
|
+
**Warning:** source-map helpers locate nodes in a document; they are not
|
|
89
|
+
validating parsers. JSON and TOML location support uses tree-sitter so it can
|
|
90
|
+
return a location from a syntax tree even when a real decoder would reject the
|
|
91
|
+
source. Parse or validate the document with your normal parser first, then use
|
|
92
|
+
these helpers only to map known error paths back to source locations.
|
|
93
|
+
|
|
94
|
+
# Version Compat
|
|
95
|
+
|
|
96
|
+
This library is compatible with Python 3.10+, but should be linted under the
|
|
97
|
+
newest stable version.
|
|
98
|
+
|
|
99
|
+
# Versioning
|
|
100
|
+
|
|
101
|
+
This library follows [meanver](https://meanver.org/) which basically means
|
|
102
|
+
[semver](https://semver.org/) along with a promise to rename when the major
|
|
103
|
+
version changes.
|
|
104
|
+
|
|
105
|
+
# License
|
|
106
|
+
|
|
107
|
+
parse-errors is copyright [Tim Hatch](https://timhatch.com/), and licensed under
|
|
108
|
+
the MIT license. See the `LICENSE` file for details.
|
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
6
|
|
|
7
|
-
|
|
8
7
|
# Matches a single step in a JSONPath: .key or [index] or ['key'] or ["key"]
|
|
9
8
|
_STEP = re.compile(
|
|
10
9
|
r"\.(?P<name>[^.\[]+)" # .key
|
|
@@ -44,7 +43,9 @@ def jsonpath_to_pointer(jsonpath: str) -> str:
|
|
|
44
43
|
raise ValueError(
|
|
45
44
|
f"Cannot parse JSONPath step at position {pos}: {tail[pos:]!r}"
|
|
46
45
|
)
|
|
47
|
-
|
|
46
|
+
for group in ("name", "sq", "dq", "idx"):
|
|
47
|
+
if (name := m.group(group)) is not None:
|
|
48
|
+
break
|
|
48
49
|
parts.append(_escape(name))
|
|
49
50
|
pos = m.end()
|
|
50
51
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.6.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 6, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = 'g81d17405e'
|
|
@@ -2,13 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import os
|
|
6
5
|
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Iterator
|
|
9
10
|
|
|
10
|
-
from .source_map import detect_format, build_source_map, closest_entry
|
|
11
11
|
from ._jsonpath import extract_jsonpath, jsonpath_to_pointer
|
|
12
|
+
from .source_map import detect_format, locate_pointer, Location
|
|
13
|
+
|
|
14
|
+
POSITIONAL_RE = re.compile(r"at line (\d+), column (\d+)")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extract_positional_reference(msg: str) -> Location | None:
|
|
18
|
+
if m := POSITIONAL_RE.search(msg):
|
|
19
|
+
return Location(
|
|
20
|
+
line=int(m.group(1)) - 1, column=int(m.group(2)) - 1, position=0
|
|
21
|
+
)
|
|
22
|
+
return None
|
|
23
|
+
|
|
12
24
|
|
|
13
25
|
__all__ = ["ParseError", "ParseContext"]
|
|
14
26
|
|
|
@@ -50,28 +62,47 @@ def ParseContext(
|
|
|
50
62
|
yield
|
|
51
63
|
except Exception as exc:
|
|
52
64
|
message = str(exc)
|
|
65
|
+
path = Path(filename)
|
|
66
|
+
|
|
53
67
|
# This is focused on msgspec-style exceptions, which use JSONPath for
|
|
54
|
-
# some reason.
|
|
55
|
-
# adjust this.
|
|
68
|
+
# some reason.
|
|
56
69
|
jsonpath = extract_jsonpath(message)
|
|
57
70
|
if jsonpath is None:
|
|
58
|
-
|
|
71
|
+
# These are raised by toml decoding
|
|
72
|
+
loc = extract_positional_reference(message)
|
|
73
|
+
if loc is None:
|
|
74
|
+
raise ParseError(
|
|
75
|
+
f"{filename}: {exc!r}", filename=filename, line=1
|
|
76
|
+
) from exc
|
|
77
|
+
|
|
78
|
+
raise ParseError(
|
|
79
|
+
f"{path}:{loc.line + 1}:{loc.column + 1}: {message}",
|
|
80
|
+
filename=path,
|
|
81
|
+
line=loc.line + 1,
|
|
82
|
+
column=loc.column + 1,
|
|
83
|
+
) from exc
|
|
59
84
|
|
|
60
85
|
try:
|
|
61
86
|
pointer = jsonpath_to_pointer(jsonpath)
|
|
62
|
-
except ValueError:
|
|
63
|
-
raise
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
87
|
+
except ValueError:
|
|
88
|
+
raise ParseError(
|
|
89
|
+
f"{filename}: {exc!r}", filename=filename, line=1
|
|
90
|
+
) from exc
|
|
91
|
+
|
|
92
|
+
fmt = format.lower() if format else detect_format(path)
|
|
93
|
+
if fmt == "yml":
|
|
94
|
+
fmt = "yaml"
|
|
95
|
+
if fmt not in ("json", "toml", "yaml"):
|
|
96
|
+
raise ParseError(
|
|
97
|
+
f"{filename}: {exc!r}", filename=filename, line=1
|
|
98
|
+
) from exc
|
|
68
99
|
|
|
69
100
|
source = data if data is not None else path.read_bytes()
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
101
|
+
entry = locate_pointer(source, fmt, pointer)
|
|
102
|
+
if entry is None:
|
|
103
|
+
raise ParseError(
|
|
104
|
+
f"{filename}: {exc!r}", filename=filename, line=1
|
|
105
|
+
) from exc
|
|
75
106
|
|
|
76
107
|
loc = entry.value_start
|
|
77
108
|
# Lines are 0-based in source maps; convert to 1-based for humans.
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Calculate JSON source locations using tree-sitter, targeted or full.
|
|
2
|
+
|
|
3
|
+
tree-sitter does the parse in C instead of a hand-written pure-Python
|
|
4
|
+
character scanner, and locate() additionally never visits a subtree that
|
|
5
|
+
isn't on the path to the one pointer asked for -- indexing directly into
|
|
6
|
+
tree-sitter's parsed node tree.
|
|
7
|
+
|
|
8
|
+
calculate() (the full map, used by anything that still wants build_source_map
|
|
9
|
+
+ closest_entry) is kept for comparison/completeness.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
import tree_sitter as ts
|
|
17
|
+
import tree_sitter_json
|
|
18
|
+
|
|
19
|
+
from .._jsonpath import _escape
|
|
20
|
+
from ..source_map import Entry, Location, TSourceMap
|
|
21
|
+
|
|
22
|
+
_LANGUAGE = ts.Language(tree_sitter_json.language())
|
|
23
|
+
|
|
24
|
+
_Parsed = tuple[ts.Node, list[bytes]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def calculate(source: str | bytes) -> TSourceMap:
|
|
28
|
+
"""Calculate the full source map for a JSON document."""
|
|
29
|
+
src_bytes = source.encode("utf-8") if isinstance(source, str) else source
|
|
30
|
+
root = ts.Parser(_LANGUAGE).parse(src_bytes).root_node
|
|
31
|
+
lines = src_bytes.splitlines(True) + [b""]
|
|
32
|
+
byte_to_char = [_byte_to_char_offsets(line) for line in lines]
|
|
33
|
+
line_start_chars = [0]
|
|
34
|
+
for offsets in byte_to_char[:-1]:
|
|
35
|
+
line_start_chars.append(line_start_chars[-1] + offsets[-1])
|
|
36
|
+
|
|
37
|
+
result: TSourceMap = {}
|
|
38
|
+
top = root.named_children[0] if root.named_children else root
|
|
39
|
+
_walk_all(top, [], result, byte_to_char, line_start_chars)
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse(source: str | bytes) -> _Parsed | None:
|
|
44
|
+
"""Parse *source* into the (node, lines) pair :func:`_locate_in` walks.
|
|
45
|
+
|
|
46
|
+
Split out from :func:`locate` so :class:`~parse_errors.source_map.SourceMap`
|
|
47
|
+
can parse once and reuse it across several ``locate()`` calls on the same
|
|
48
|
+
document, instead of reparsing per pointer.
|
|
49
|
+
"""
|
|
50
|
+
src_bytes = source.encode("utf-8") if isinstance(source, str) else source
|
|
51
|
+
root = ts.Parser(_LANGUAGE).parse(src_bytes).root_node
|
|
52
|
+
lines = src_bytes.splitlines(True) + [b""]
|
|
53
|
+
top = root.named_children[0] if root.named_children else root
|
|
54
|
+
return top, lines
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _locate_in(parsed: _Parsed | None, pointer: str) -> Entry | None:
|
|
58
|
+
if parsed is None:
|
|
59
|
+
return None
|
|
60
|
+
top, lines = parsed
|
|
61
|
+
segments = _pointer_segments(pointer)
|
|
62
|
+
return _walk_targeted(top, segments, 0, lines)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def locate(source: str | bytes, pointer: str) -> Entry | None:
|
|
66
|
+
"""Find *pointer*'s location, visiting only nodes on the path to it."""
|
|
67
|
+
return _locate_in(_parse(source), pointer)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --- shared helpers ---
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _pointer_segments(pointer: str) -> list[str]:
|
|
74
|
+
if not pointer:
|
|
75
|
+
return []
|
|
76
|
+
return [p.replace("~1", "/").replace("~0", "~") for p in pointer.split("/")[1:]]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _to_pointer(segments: list[str]) -> str:
|
|
80
|
+
return "/" + "/".join(_escape(s) for s in segments) if segments else ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _byte_to_char_offsets(line: bytes) -> list[int]:
|
|
84
|
+
offsets = [0] * (len(line) + 1)
|
|
85
|
+
char_count = 0
|
|
86
|
+
for i, b in enumerate(line):
|
|
87
|
+
if b < 0x80 or b >= 0xC0:
|
|
88
|
+
char_count += 1
|
|
89
|
+
offsets[i + 1] = char_count
|
|
90
|
+
return offsets
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --- full map (calculate) ---
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _walk_all(
|
|
97
|
+
node: ts.Node,
|
|
98
|
+
segments: list[str],
|
|
99
|
+
result: TSourceMap,
|
|
100
|
+
byte_to_char: list[list[int]],
|
|
101
|
+
line_start_chars: list[int],
|
|
102
|
+
key_node: ts.Node | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
pointer = _to_pointer(segments)
|
|
105
|
+
result[pointer] = Entry(
|
|
106
|
+
value_start=_loc_precomputed(node.start_point, byte_to_char, line_start_chars),
|
|
107
|
+
value_end=_loc_precomputed(node.end_point, byte_to_char, line_start_chars),
|
|
108
|
+
key_start=_loc_precomputed(key_node.start_point, byte_to_char, line_start_chars)
|
|
109
|
+
if key_node
|
|
110
|
+
else None,
|
|
111
|
+
key_end=_loc_precomputed(key_node.end_point, byte_to_char, line_start_chars)
|
|
112
|
+
if key_node
|
|
113
|
+
else None,
|
|
114
|
+
)
|
|
115
|
+
if node.type == "object":
|
|
116
|
+
for pair in node.named_children:
|
|
117
|
+
if pair.type != "pair":
|
|
118
|
+
continue # pragma: no cover
|
|
119
|
+
k = pair.child_by_field_name("key")
|
|
120
|
+
v = pair.child_by_field_name("value")
|
|
121
|
+
assert k is not None and k.text is not None
|
|
122
|
+
assert v is not None
|
|
123
|
+
key_text = json.loads(k.text.decode("utf-8"))
|
|
124
|
+
_walk_all(
|
|
125
|
+
v,
|
|
126
|
+
segments + [key_text],
|
|
127
|
+
result,
|
|
128
|
+
byte_to_char,
|
|
129
|
+
line_start_chars,
|
|
130
|
+
key_node=k,
|
|
131
|
+
)
|
|
132
|
+
elif node.type == "array":
|
|
133
|
+
for i, child in enumerate(node.named_children):
|
|
134
|
+
_walk_all(
|
|
135
|
+
child, segments + [str(i)], result, byte_to_char, line_start_chars
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _loc_precomputed(
|
|
140
|
+
point: ts.Point, byte_to_char: list[list[int]], line_start_chars: list[int]
|
|
141
|
+
) -> Location:
|
|
142
|
+
char_column = byte_to_char[point.row][point.column]
|
|
143
|
+
return Location(
|
|
144
|
+
line=point.row,
|
|
145
|
+
column=char_column,
|
|
146
|
+
position=line_start_chars[point.row] + char_column,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --- targeted lookup ---
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _walk_targeted(
|
|
154
|
+
node: ts.Node,
|
|
155
|
+
segments: list[str],
|
|
156
|
+
idx: int,
|
|
157
|
+
lines: list[bytes],
|
|
158
|
+
key_node: ts.Node | None = None,
|
|
159
|
+
) -> Entry:
|
|
160
|
+
if idx == len(segments):
|
|
161
|
+
return Entry(
|
|
162
|
+
value_start=_loc(node.start_point, lines),
|
|
163
|
+
value_end=_loc(node.end_point, lines),
|
|
164
|
+
key_start=_loc(key_node.start_point, lines)
|
|
165
|
+
if key_node is not None
|
|
166
|
+
else None,
|
|
167
|
+
key_end=_loc(key_node.end_point, lines) if key_node is not None else None,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if node.type == "object":
|
|
171
|
+
target_key = segments[idx]
|
|
172
|
+
for pair in node.named_children:
|
|
173
|
+
if pair.type != "pair":
|
|
174
|
+
continue # pragma: no cover
|
|
175
|
+
k = pair.child_by_field_name("key")
|
|
176
|
+
assert k is not None and k.text is not None
|
|
177
|
+
key_text = json.loads(k.text.decode("utf-8"))
|
|
178
|
+
if key_text == target_key:
|
|
179
|
+
v = pair.child_by_field_name("value")
|
|
180
|
+
assert v is not None
|
|
181
|
+
return _walk_targeted(v, segments, idx + 1, lines, key_node=k)
|
|
182
|
+
# target_key not present -- this object is the closest ancestor
|
|
183
|
+
return _ancestor(node, lines, key_node)
|
|
184
|
+
|
|
185
|
+
if node.type == "array":
|
|
186
|
+
try:
|
|
187
|
+
target_index = int(segments[idx])
|
|
188
|
+
except ValueError:
|
|
189
|
+
target_index = -1
|
|
190
|
+
children = node.named_children
|
|
191
|
+
if 0 <= target_index < len(children):
|
|
192
|
+
return _walk_targeted(children[target_index], segments, idx + 1, lines)
|
|
193
|
+
return _ancestor(node, lines, key_node)
|
|
194
|
+
|
|
195
|
+
# A scalar, but the pointer wants to go deeper -- no such path; this
|
|
196
|
+
# value is the closest ancestor.
|
|
197
|
+
return _ancestor(node, lines, key_node)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _ancestor(node: ts.Node, lines: list[bytes], key_node: ts.Node | None) -> Entry:
|
|
201
|
+
# A fallback entry still carries key_start/key_end when this node is
|
|
202
|
+
# itself the value of an enclosing key -- matching the entry it would
|
|
203
|
+
# have gotten in the full map, at the pointer for that key.
|
|
204
|
+
return Entry(
|
|
205
|
+
value_start=_loc(node.start_point, lines),
|
|
206
|
+
value_end=_loc(node.end_point, lines),
|
|
207
|
+
key_start=_loc(key_node.start_point, lines) if key_node is not None else None,
|
|
208
|
+
key_end=_loc(key_node.end_point, lines) if key_node is not None else None,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _loc(point: ts.Point, lines: list[bytes]) -> Location:
|
|
213
|
+
# Only called O(depth) times per locate() call (a handful), so decoding
|
|
214
|
+
# fresh each time -- rather than precomputing a whole-document table --
|
|
215
|
+
# is cheap; the table only paid for itself when amortized over every
|
|
216
|
+
# node in the document, which locate() deliberately never visits.
|
|
217
|
+
line = lines[point.row]
|
|
218
|
+
char_column = len(line[: point.column].decode("utf-8"))
|
|
219
|
+
position = (
|
|
220
|
+
sum(len(prior.decode("utf-8")) for prior in lines[: point.row]) + char_column
|
|
221
|
+
)
|
|
222
|
+
return Location(line=point.row, column=char_column, position=position)
|