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.
Files changed (49) hide show
  1. {parse_errors-0.5.0 → parse_errors-0.6.0}/.github/workflows/build.yml +5 -7
  2. parse_errors-0.6.0/MANIFEST.in +1 -0
  3. parse_errors-0.6.0/Makefile +22 -0
  4. parse_errors-0.6.0/PKG-INFO +143 -0
  5. parse_errors-0.6.0/README.md +108 -0
  6. {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors/_jsonpath.py +3 -2
  7. parse_errors-0.6.0/parse_errors/_version.py +24 -0
  8. {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors/context.py +47 -16
  9. parse_errors-0.6.0/parse_errors/json_source_map/__init__.py +222 -0
  10. {parse_errors-0.5.0/parse_errors/yaml_source_map → parse_errors-0.6.0/parse_errors/json_source_map}/__main__.py +1 -0
  11. parse_errors-0.6.0/parse_errors/source_map.py +155 -0
  12. parse_errors-0.6.0/parse_errors/toml_source_map/__init__.py +294 -0
  13. {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors/toml_source_map/__main__.py +1 -0
  14. parse_errors-0.6.0/parse_errors/yaml_source_map/__init__.py +160 -0
  15. {parse_errors-0.5.0/parse_errors/json_source_map → parse_errors-0.6.0/parse_errors/yaml_source_map}/__main__.py +1 -0
  16. parse_errors-0.6.0/parse_errors.egg-info/PKG-INFO +143 -0
  17. {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors.egg-info/SOURCES.txt +3 -2
  18. {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors.egg-info/requires.txt +8 -6
  19. parse_errors-0.6.0/parse_errors.egg-info/scm_file_list.json +30 -0
  20. parse_errors-0.6.0/parse_errors.egg-info/scm_version.json +8 -0
  21. {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors.egg-info/top_level.txt +0 -1
  22. parse_errors-0.6.0/pyproject.toml +105 -0
  23. parse_errors-0.6.0/setup.cfg +4 -0
  24. {parse_errors-0.5.0 → parse_errors-0.6.0}/tests/test_parse_context_json.py +31 -3
  25. {parse_errors-0.5.0 → parse_errors-0.6.0}/tests/test_parse_context_toml.py +20 -6
  26. parse_errors-0.6.0/tests/test_source_map.py +327 -0
  27. parse_errors-0.5.0/MANIFEST.in +0 -2
  28. parse_errors-0.5.0/Makefile +0 -37
  29. parse_errors-0.5.0/PKG-INFO +0 -46
  30. parse_errors-0.5.0/README.md +0 -18
  31. parse_errors-0.5.0/parse_errors/_version.py +0 -34
  32. parse_errors-0.5.0/parse_errors/json_source_map/__init__.py +0 -25
  33. parse_errors-0.5.0/parse_errors/source_map.py +0 -77
  34. parse_errors-0.5.0/parse_errors/toml_source_map/__init__.py +0 -194
  35. parse_errors-0.5.0/parse_errors/yaml_source_map/__init__.py +0 -62
  36. parse_errors-0.5.0/parse_errors.egg-info/PKG-INFO +0 -46
  37. parse_errors-0.5.0/setup.cfg +0 -79
  38. parse_errors-0.5.0/setup.py +0 -3
  39. parse_errors-0.5.0/tests/test_source_map.py +0 -41
  40. {parse_errors-0.5.0 → parse_errors-0.6.0}/.gitignore +0 -0
  41. {parse_errors-0.5.0 → parse_errors-0.6.0}/.vars.ini +0 -0
  42. {parse_errors-0.5.0 → parse_errors-0.6.0}/LICENSE +0 -0
  43. {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors/__init__.py +0 -0
  44. {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors/py.typed +0 -0
  45. {parse_errors-0.5.0 → parse_errors-0.6.0}/parse_errors.egg-info/dependency_links.txt +0 -0
  46. {parse_errors-0.5.0 → parse_errors-0.6.0}/tests/__init__.py +0 -0
  47. {parse_errors-0.5.0 → parse_errors-0.6.0}/tests/_types.py +0 -0
  48. {parse_errors-0.5.0 → parse_errors-0.6.0}/tests/conftest.py +0 -0
  49. {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 pip install build
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
- name = m.group("name") or m.group("sq") or m.group("dq") or m.group("idx")
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. If there are other formats we know can be raised,
55
- # adjust this.
68
+ # some reason.
56
69
  jsonpath = extract_jsonpath(message)
57
70
  if jsonpath is None:
58
- raise
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: # pragma: no cover
63
- raise exc
64
-
65
- path = Path(filename)
66
- fmt = format or detect_format(path)
67
- assert fmt is not None
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
- source_map = build_source_map(source, fmt)
71
-
72
- entry = closest_entry(source_map, pointer)
73
- if entry is None: # pragma: no cover
74
- raise exc
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)
@@ -1,5 +1,6 @@
1
1
  if __name__ == "__main__": # pragma: no cover
2
2
  import sys
3
+
3
4
  from . import calculate
4
5
 
5
6
  source = open(sys.argv[1]).read()