partialjson 1.0.0__tar.gz → 1.2.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 (29) hide show
  1. partialjson-1.2.0/CHANGELOG.md +117 -0
  2. partialjson-1.2.0/CITATION.cff +13 -0
  3. partialjson-1.2.0/MANIFEST.in +3 -0
  4. partialjson-1.2.0/PKG-INFO +153 -0
  5. partialjson-1.0.0/PKG-INFO → partialjson-1.2.0/README.md +46 -25
  6. partialjson-1.2.0/partialjson/__init__.py +24 -0
  7. partialjson-1.2.0/partialjson/json5_parser.py +235 -0
  8. partialjson-1.2.0/partialjson/json_parser.py +403 -0
  9. partialjson-1.2.0/partialjson/py.typed +0 -0
  10. partialjson-1.2.0/partialjson.egg-info/PKG-INFO +153 -0
  11. partialjson-1.2.0/partialjson.egg-info/SOURCES.txt +21 -0
  12. partialjson-1.2.0/partialjson.egg-info/requires.txt +3 -0
  13. partialjson-1.2.0/pyproject.toml +61 -0
  14. partialjson-1.2.0/tests/fuzz_compat_1_1_0.py +81 -0
  15. partialjson-1.2.0/tests/legacy_1_1_0_parser.py +236 -0
  16. partialjson-1.2.0/tests/test_compat_1_1_0.py +127 -0
  17. partialjson-1.2.0/tests/test_json5.py +58 -0
  18. {partialjson-1.0.0 → partialjson-1.2.0}/tests/test_parser.py +7 -6
  19. partialjson-1.2.0/tests/test_regressions.py +251 -0
  20. partialjson-1.0.0/README.md +0 -79
  21. partialjson-1.0.0/partialjson/__init__.py +0 -20
  22. partialjson-1.0.0/partialjson/json_parser.py +0 -168
  23. partialjson-1.0.0/partialjson.egg-info/PKG-INFO +0 -98
  24. partialjson-1.0.0/partialjson.egg-info/SOURCES.txt +0 -10
  25. partialjson-1.0.0/setup.py +0 -34
  26. {partialjson-1.0.0 → partialjson-1.2.0}/LICENSE +0 -0
  27. {partialjson-1.0.0 → partialjson-1.2.0}/partialjson.egg-info/dependency_links.txt +0 -0
  28. {partialjson-1.0.0 → partialjson-1.2.0}/partialjson.egg-info/top_level.txt +0 -0
  29. {partialjson-1.0.0 → partialjson-1.2.0}/setup.cfg +0 -0
@@ -0,0 +1,117 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.2.0] - 2026-09-12
9
+
10
+ No API changes. Every input that 1.1.0 parsed successfully parses to the same
11
+ value in 1.2.0, verified by `tests/test_compat_1_1_0.py`, which runs the frozen
12
+ 1.1.0 parser next to the current one over every prefix of a corpus of documents.
13
+
14
+ ### Fixed
15
+
16
+ - Numbers with an exponent (`1e5`, `2.5E-3`) inside an incomplete array or
17
+ object raised `JSONDecodeError`. They now parse; an exponent that has not
18
+ received its digits yet (`1e`, `1e-`) is dropped until it is complete.
19
+ - In strict mode an unterminated string whose tail was an incomplete escape
20
+ (`"foo\`, `"foo\u00`) returned `""`, discarding text that had already
21
+ streamed. It now returns `"foo"`; only the unfinished escape is held back
22
+ (issue #8).
23
+ - In strict mode a string cut between the two halves of a surrogate pair
24
+ (`"\ud83d`, half of an emoji) returned a lone surrogate, which raises
25
+ `UnicodeEncodeError` as soon as it is encoded. The high half is now held
26
+ back until its partner arrives.
27
+ - The JSON5 parser raised on partial literals (`{"a": tr`, `[fals`, `[Inf`)
28
+ and on exponent numbers, and treated a comment that had only streamed its
29
+ first `/` as an unknown token. It now behaves like the JSON parser.
30
+ - JSON5 string decoding no longer depends on whether the optional `json5`
31
+ package is installed; the same escapes (`\x41`, `\'`, line continuations,
32
+ surrogate pairs) decode the same way either way.
33
+ - `bytes` and `bytearray` input, which `json.loads` accepts, no longer crash
34
+ the fallback parser with `AttributeError`. A chunk that ends in the middle
35
+ of a multi-byte UTF-8 character drops the incomplete bytes.
36
+ - A leading UTF-8 byte-order mark no longer causes a `JSONDecodeError`.
37
+ - `JSONParser.strict`, `.on_extra_token` and `.last_parse_reminding` are
38
+ readable and assignable again (assigning `strict` on a 1.x parser was
39
+ silently ignored), and the 0.x method names `parse_string`, `parse_number`,
40
+ `parse_array`, `parse_object`, `parse_true`, `parse_false`, `parse_null`
41
+ and `parse_space` are callable again.
42
+
43
+ ### Changed
44
+
45
+ - The scanner works on string indexes instead of re-slicing the input at
46
+ every token, so a parse is linear in the input size. A 900 KB partial
47
+ document went from about 1 s to about 60 ms per `parse()` call.
48
+ - A literal that is not a prefix of `true`/`false`/`null` (for example
49
+ `[trap]`, which 1.1.0 returned as `[True]`) now raises, matching
50
+ `json.loads`. Prefixes such as `[t`, `[tru` still parse.
51
+ - `_JSON5Parser` is now a subclass of the JSON parser instead of a copy of it.
52
+ - Packaging moved to `pyproject.toml` with `requires-python >= 3.8`,
53
+ classifiers and a `py.typed` marker; the package is fully type-annotated.
54
+ - CI runs on Python 3.8 through 3.14, with and without the optional `json5`
55
+ dependency.
56
+
57
+ ## [1.1.0] - 2026-02-20
58
+
59
+ ### Added
60
+
61
+ - JSON5 support: comments, unquoted keys, single-quoted strings, hex numbers,
62
+ `Infinity`/`NaN`, trailing commas. Available through
63
+ `create_json5_parser()` or `JSONParser(json5_enabled=True)`; install
64
+ `partialjson[json5]` for the optional `json5` fast path (issue #9).
65
+ - `create_json_parser()` factory.
66
+
67
+ ## [1.0.0] - 2026-02
68
+
69
+ ### Added
70
+
71
+ - `CITATION.cff`, `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md`, JOSS paper draft,
72
+ GitHub Actions test workflow.
73
+
74
+ ### Changed
75
+
76
+ - `JSONParser` became a thin facade over an internal implementation class.
77
+
78
+ ## [0.1.0] - 2025-01-28
79
+
80
+ ### Fixed
81
+
82
+ - Incomplete escape sequences (`"\`, `"\u12`) at the end of a streamed
83
+ string no longer raise (issue #8).
84
+
85
+ ## [0.0.8] - 2024-08-03
86
+
87
+ ### Added
88
+
89
+ - Support `strict` mode based on [this issue](https://github.com/iw4p/partialjson/issues/5)
90
+ - Test cases for `parser_strict` and `parser_non_strict` to handle incomplete and complete JSON strings with newline characters.
91
+ - Example usage of both strict and non-strict parsers in the unit tests.
92
+ - Unit tests for various number, string, boolean, array, and object parsing scenarios.
93
+
94
+ ### Changed
95
+
96
+ - Updated incomplete number parsing logic to ensure better error handling and test coverage.
97
+
98
+ ### Fixed
99
+
100
+ - Fixed issue with parsing incomplete floating point numbers where the parser incorrectly returned an error.
101
+ - Corrected string parsing logic to properly handle escape characters in strict mode.
102
+
103
+ ## [0.0.2] - 2023-11-24
104
+
105
+ ### Added
106
+
107
+ ### Changed
108
+
109
+ ### Fixed
110
+
111
+ - json format
112
+
113
+ ## [0.0.1] - 2023-11-24
114
+
115
+ ### Added
116
+
117
+ - Initial implementation of `JSONParser` with support for only strict mode.
@@ -0,0 +1,13 @@
1
+ cff-version: 1.2.0
2
+ title: partialjson
3
+ message: "If you use this software, please cite it as below."
4
+ type: software
5
+ authors:
6
+ - family-names: Akbarzadeh
7
+ given-names: Nima
8
+ orcid: "https://orcid.org/0009-0005-8143-8083"
9
+ repository-code: "https://github.com/iw4p/partialjson"
10
+ keywords: [python, json, parsing, streaming]
11
+ version: 1.0.0
12
+ date-released: 2025-10-05
13
+
@@ -0,0 +1,3 @@
1
+ include LICENSE README.md CHANGELOG.md CITATION.cff
2
+ graft tests
3
+ global-exclude __pycache__ *.py[cod]
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: partialjson
3
+ Version: 1.2.0
4
+ Summary: Parse incomplete or partial JSON, e.g. from a streaming LLM response
5
+ Author-email: Nima Akbarzadeh <iw4p@protonmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/iw4p/partialjson
8
+ Project-URL: Repository, https://github.com/iw4p/partialjson
9
+ Project-URL: Changelog, https://github.com/iw4p/partialjson/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/iw4p/partialjson/issues
11
+ Keywords: json,partial,incomplete,streaming,llm,openai,json5
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.8
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Classifier: Topic :: Text Processing
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.8
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Provides-Extra: json5
32
+ Requires-Dist: json5; extra == "json5"
33
+ Dynamic: license-file
34
+
35
+ # PartialJson
36
+
37
+ [![Partialjson](https://github.com/iw4p/partialjson/raw/main/images/partialjson.png)](https://pypi.org/project/partialjson/)
38
+
39
+ ## Parse Partial and incomplete JSON in python
40
+
41
+ ![](https://github.com/iw4p/partialjson/raw/main/images/partialjson.gif)
42
+
43
+ ### Parse Partial and incomplete JSON in python with just 3 lines of python code.
44
+
45
+ [![PyPI version](https://img.shields.io/pypi/v/partialjson.svg)](https://pypi.org/project/partialjson)
46
+ [![Supported Python versions](https://img.shields.io/pypi/pyversions/partialjson.svg)](#Installation)
47
+ [![Downloads](https://pepy.tech/badge/partialjson)](https://pepy.tech/project/partialjson)
48
+
49
+ ## Example
50
+
51
+ ```python
52
+ from partialjson import JSONParser
53
+ parser = JSONParser()
54
+
55
+ incomplete_json = '{"name": "John Doe", "age": 30, "is_student": false, "courses": ["Math", "Science"'
56
+ print(parser.parse(incomplete_json))
57
+ # {'name': 'John Doe', 'age': 30, 'is_student': False, 'courses': ['Math', 'Science']}
58
+ ```
59
+
60
+ Problem with `\n`? Use `strict=False`:
61
+
62
+ ```python
63
+ from partialjson import JSONParser
64
+ parser = JSONParser(strict=False)
65
+
66
+ incomplete_json = '{"name": "John\nDoe", "age": 30, "is_student": false, "courses": ["Math", "Science"'
67
+ print(parser.parse(incomplete_json))
68
+ # {'name': 'John\nDoe', 'age': 30, 'is_student': False, 'courses': ['Math', 'Science']}
69
+ ```
70
+
71
+ ### JSON5 support
72
+
73
+ Use `create_json5_parser` or `JSONParser(json5_enabled=True)` for JSON5 (comments, unquoted keys, single quotes, etc.):
74
+
75
+ ```python
76
+ from partialjson import create_json5_parser
77
+ parser = create_json5_parser()
78
+
79
+ incomplete_json5 = '{name: "Demo", version: 1.0, items: [1, 2, 3,]'
80
+ print(parser.parse(incomplete_json5))
81
+ # {'name': 'Demo', 'version': 1.0, 'items': [1, 2, 3]}
82
+ ```
83
+
84
+ The optional `json5` dependency speeds up parsing of complete JSON5 documents: `pip install partialjson[json5]`. Partial documents parse the same way with or without it.
85
+
86
+ ### What you get while a string is still streaming
87
+
88
+ Text that has already arrived is returned; only what cannot be decided yet is held back. With `strict=True` (the default) escapes are decoded and an unfinished escape or half an emoji is dropped until it is complete:
89
+
90
+ ```python
91
+ parser.parse('{"msg": "caf\\u00') # {'msg': 'caf'}
92
+ parser.parse('{"msg": "caf\\u00e9"') # {'msg': 'café'}
93
+ parser.parse('{"msg": "hi \\ud83d"') # {'msg': 'hi '}
94
+ parser.parse('{"msg": "hi \\ud83d\\ude00"') # {'msg': 'hi 😀'}
95
+ ```
96
+
97
+ With `strict=False` the raw text of an unfinished string is returned untouched, backslashes included.
98
+
99
+ ### Extra tokens
100
+
101
+ If the input contains a complete value followed by more text, the value is returned and the callback passed as `on_extra_token` is called with the input, the value and the leftover text. The default callback prints to stdout; pass `on_extra_token=None` to silence it, or read `parser.last_parse_reminding` afterwards.
102
+
103
+ ```python
104
+ parser = JSONParser(on_extra_token=None)
105
+ parser.parse('{"a": 1} trailing') # {'a': 1}
106
+ parser.last_parse_reminding # ' trailing'
107
+ ```
108
+
109
+ ### Installation
110
+
111
+ ```sh
112
+ $ pip install partialjson
113
+ ```
114
+
115
+ Also can be found on [pypi](https://pypi.org/project/partialjson/)
116
+
117
+ ### How can I use it?
118
+
119
+ - Install the package by pip package manager.
120
+ - After installing, you can use it and call the library.
121
+
122
+ ## Testing
123
+
124
+ ```bash
125
+ pip install -e '.[json5]'
126
+ pip install -r requirements-dev.txt
127
+ pytest -q
128
+ ```
129
+
130
+ `tests/test_compat_1_1_0.py` runs the frozen 1.1.0 parser next to the current one over every prefix of a corpus of documents, so behaviour changes for existing users show up as test failures.
131
+
132
+ ## Citation
133
+
134
+ If you use this software, please cite it using the metadata in `CITATION.cff`.
135
+
136
+ ## Star History
137
+
138
+ [![Star History Chart](https://api.star-history.com/svg?repos=iw4p/partialjson&type=Date)](https://star-history.com/#iw4p/partialjson&Date)
139
+
140
+ ### Issues
141
+
142
+ Feel free to submit issues and enhancement requests or contact me via [vida.page/nima](https://vida.page/nima).
143
+
144
+ ### Contributing
145
+
146
+ Please refer to each project's style and contribution guidelines for submitting patches and additions. In general, we follow the "fork-and-pull" Git workflow.
147
+
148
+ 1. **Fork** the repo on GitHub
149
+ 2. **Clone** the project to your own machine
150
+ 3. **Update the Version** inside `partialjson/__init__.py` and add a `CHANGELOG.md` entry
151
+ 4. **Commit** changes to your own branch
152
+ 5. **Push** your work back up to your fork
153
+ 6. Submit a **Pull request** so that we can review your changes
@@ -1,22 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: partialjson
3
- Version: 1.0.0
4
- Summary: Parse incomplete or partial json
5
- Home-page: https://github.com/iw4p/partialjson
6
- Author: Nima Akbarzadeh
7
- Author-email: iw4p@protonmail.com
8
- License: MIT
9
- Description-Content-Type: text/markdown
10
- License-File: LICENSE
11
- Dynamic: author
12
- Dynamic: author-email
13
- Dynamic: description
14
- Dynamic: description-content-type
15
- Dynamic: home-page
16
- Dynamic: license
17
- Dynamic: license-file
18
- Dynamic: summary
19
-
20
1
  # PartialJson
21
2
 
22
3
  [![Partialjson](https://github.com/iw4p/partialjson/raw/main/images/partialjson.png)](https://pypi.org/project/partialjson/)
@@ -34,18 +15,18 @@ Dynamic: summary
34
15
  ## Example
35
16
 
36
17
  ```python
37
- from partialjson.json_parser import JSONParser
18
+ from partialjson import JSONParser
38
19
  parser = JSONParser()
39
20
 
40
21
  incomplete_json = '{"name": "John Doe", "age": 30, "is_student": false, "courses": ["Math", "Science"'
41
22
  print(parser.parse(incomplete_json))
42
- # {'name': 'John', 'age': 30, 'is_student': False, 'courses': ['Math', 'Science']}
23
+ # {'name': 'John Doe', 'age': 30, 'is_student': False, 'courses': ['Math', 'Science']}
43
24
  ```
44
25
 
45
- Problem with `\n`? strict mode is here
26
+ Problem with `\n`? Use `strict=False`:
46
27
 
47
28
  ```python
48
- from partialjson.json_parser import JSONParser
29
+ from partialjson import JSONParser
49
30
  parser = JSONParser(strict=False)
50
31
 
51
32
  incomplete_json = '{"name": "John\nDoe", "age": 30, "is_student": false, "courses": ["Math", "Science"'
@@ -53,6 +34,44 @@ print(parser.parse(incomplete_json))
53
34
  # {'name': 'John\nDoe', 'age': 30, 'is_student': False, 'courses': ['Math', 'Science']}
54
35
  ```
55
36
 
37
+ ### JSON5 support
38
+
39
+ Use `create_json5_parser` or `JSONParser(json5_enabled=True)` for JSON5 (comments, unquoted keys, single quotes, etc.):
40
+
41
+ ```python
42
+ from partialjson import create_json5_parser
43
+ parser = create_json5_parser()
44
+
45
+ incomplete_json5 = '{name: "Demo", version: 1.0, items: [1, 2, 3,]'
46
+ print(parser.parse(incomplete_json5))
47
+ # {'name': 'Demo', 'version': 1.0, 'items': [1, 2, 3]}
48
+ ```
49
+
50
+ The optional `json5` dependency speeds up parsing of complete JSON5 documents: `pip install partialjson[json5]`. Partial documents parse the same way with or without it.
51
+
52
+ ### What you get while a string is still streaming
53
+
54
+ Text that has already arrived is returned; only what cannot be decided yet is held back. With `strict=True` (the default) escapes are decoded and an unfinished escape or half an emoji is dropped until it is complete:
55
+
56
+ ```python
57
+ parser.parse('{"msg": "caf\\u00') # {'msg': 'caf'}
58
+ parser.parse('{"msg": "caf\\u00e9"') # {'msg': 'café'}
59
+ parser.parse('{"msg": "hi \\ud83d"') # {'msg': 'hi '}
60
+ parser.parse('{"msg": "hi \\ud83d\\ude00"') # {'msg': 'hi 😀'}
61
+ ```
62
+
63
+ With `strict=False` the raw text of an unfinished string is returned untouched, backslashes included.
64
+
65
+ ### Extra tokens
66
+
67
+ If the input contains a complete value followed by more text, the value is returned and the callback passed as `on_extra_token` is called with the input, the value and the leftover text. The default callback prints to stdout; pass `on_extra_token=None` to silence it, or read `parser.last_parse_reminding` afterwards.
68
+
69
+ ```python
70
+ parser = JSONParser(on_extra_token=None)
71
+ parser.parse('{"a": 1} trailing') # {'a': 1}
72
+ parser.last_parse_reminding # ' trailing'
73
+ ```
74
+
56
75
  ### Installation
57
76
 
58
77
  ```sh
@@ -69,11 +88,13 @@ Also can be found on [pypi](https://pypi.org/project/partialjson/)
69
88
  ## Testing
70
89
 
71
90
  ```bash
72
- pip install -e .
91
+ pip install -e '.[json5]'
73
92
  pip install -r requirements-dev.txt
74
93
  pytest -q
75
94
  ```
76
95
 
96
+ `tests/test_compat_1_1_0.py` runs the frozen 1.1.0 parser next to the current one over every prefix of a corpus of documents, so behaviour changes for existing users show up as test failures.
97
+
77
98
  ## Citation
78
99
 
79
100
  If you use this software, please cite it using the metadata in `CITATION.cff`.
@@ -92,7 +113,7 @@ Please refer to each project's style and contribution guidelines for submitting
92
113
 
93
114
  1. **Fork** the repo on GitHub
94
115
  2. **Clone** the project to your own machine
95
- 3. **Update the Version** inside **init**.py
116
+ 3. **Update the Version** inside `partialjson/__init__.py` and add a `CHANGELOG.md` entry
96
117
  4. **Commit** changes to your own branch
97
118
  5. **Push** your work back up to your fork
98
119
  6. Submit a **Pull request** so that we can review your changes
@@ -0,0 +1,24 @@
1
+ """
2
+ Partial Json.
3
+
4
+ Parse partial and incomplete JSON, such as a streaming LLM response, without
5
+ crashing: ``JSONParser().parse('{"a": [1, 2')`` returns ``{"a": [1, 2]}``.
6
+ """
7
+
8
+ from .json5_parser import create_json5_parser
9
+ from .json_parser import JSONParser, create_json_parser
10
+
11
+ __version__ = "1.2.0"
12
+ __author__ = "Nima Akbarzadeh"
13
+ __author_email__ = "iw4p@protonmail.com"
14
+ __license__ = "MIT"
15
+ __url__ = "https://github.com/iw4p/partialjson"
16
+
17
+ PYPI_SIMPLE_ENDPOINT: str = "https://pypi.org/project/partialjson"
18
+
19
+ __all__ = [
20
+ "PYPI_SIMPLE_ENDPOINT",
21
+ "JSONParser",
22
+ "create_json5_parser",
23
+ "create_json_parser",
24
+ ]
@@ -0,0 +1,235 @@
1
+ """JSON5 parser - extends JSON with comments, unquoted keys, single quotes, etc.
2
+
3
+ Built on top of the JSON scanner in ``json_parser``; only the JSON5-specific
4
+ pieces (whitespace and comments, identifiers, extra string escapes, hex and
5
+ signed numbers, ``Infinity``/``NaN``, case-insensitive literals) are overridden.
6
+ """
7
+ import json
8
+ from types import ModuleType
9
+ from typing import Any, ClassVar, FrozenSet, Optional, Tuple
10
+
11
+ from .json_parser import (
12
+ _HEX,
13
+ _NO_KEY,
14
+ OnExtraToken,
15
+ ScanResult,
16
+ _default_on_extra_token,
17
+ _is_high_surrogate,
18
+ _is_low_surrogate,
19
+ _JSONParser,
20
+ )
21
+
22
+ json5: Optional[ModuleType]
23
+ try:
24
+ import json5
25
+ except ImportError: # pragma: no cover - exercised via monkeypatching in tests
26
+ json5 = None
27
+
28
+ __all__ = ["_default_on_extra_token", "create_json5_parser"]
29
+
30
+ _JSON5_WHITESPACE = "\v\f\u00A0\u2028\u2029\uFEFF"
31
+ _LINE_TERMINATORS = "\n\r\u2028\u2029"
32
+ _SIMPLE_ESCAPES = {
33
+ "b": "\b",
34
+ "f": "\f",
35
+ "n": "\n",
36
+ "r": "\r",
37
+ "t": "\t",
38
+ "v": "\v",
39
+ "0": "\0",
40
+ }
41
+
42
+
43
+ def create_json5_parser(
44
+ strict: bool = True, on_extra_token: Optional[OnExtraToken] = None
45
+ ) -> "_JSON5Parser":
46
+ """Create a JSON5 parser."""
47
+ return _JSON5Parser(strict=strict, on_extra_token=on_extra_token)
48
+
49
+
50
+ def _decode_json5_string(content: str) -> str:
51
+ """Decode the body of a JSON5 string literal (quotes already removed)."""
52
+ out = []
53
+ i = 0
54
+ n = len(content)
55
+ while i < n:
56
+ c = content[i]
57
+ if c != "\\":
58
+ out.append(c)
59
+ i += 1
60
+ continue
61
+ if i + 1 >= n:
62
+ raise ValueError("incomplete escape")
63
+ esc = content[i + 1]
64
+ if esc == "u":
65
+ hex4 = content[i + 2 : i + 6]
66
+ if len(hex4) < 4 or any(h not in _HEX for h in hex4):
67
+ raise ValueError("bad \\u escape")
68
+ code = int(hex4, 16)
69
+ i += 6
70
+ if _is_high_surrogate(hex4) and content[i : i + 2] == "\\u":
71
+ low = content[i + 2 : i + 6]
72
+ if len(low) == 4 and all(h in _HEX for h in low) and _is_low_surrogate(low):
73
+ code = 0x10000 + ((code - 0xD800) << 10) + (int(low, 16) - 0xDC00)
74
+ i += 6
75
+ out.append(chr(code))
76
+ elif esc == "x":
77
+ hex2 = content[i + 2 : i + 4]
78
+ if len(hex2) < 2 or any(h not in _HEX for h in hex2):
79
+ raise ValueError("bad \\x escape")
80
+ out.append(chr(int(hex2, 16)))
81
+ i += 4
82
+ elif esc == "\r":
83
+ i += 3 if content[i + 2 : i + 3] == "\n" else 2
84
+ elif esc in _LINE_TERMINATORS:
85
+ i += 2 # line continuation
86
+ elif esc in _SIMPLE_ESCAPES:
87
+ out.append(_SIMPLE_ESCAPES[esc])
88
+ i += 2
89
+ else:
90
+ out.append(esc) # \' \" \\ \/ and any other escaped character
91
+ i += 2
92
+ return "".join(out)
93
+
94
+
95
+ class _JSON5Parser(_JSONParser):
96
+ """JSON5 parser with comments, unquoted keys, single quotes, hex, Infinity, etc."""
97
+
98
+ _VALUE_START: ClassVar[FrozenSet[str]] = frozenset("[{\"'tfnTFNI+0123456789.-")
99
+
100
+ # ------------------------------------------------------------ fast path
101
+
102
+ def _loads(self, s: str) -> Any:
103
+ try:
104
+ return json.loads(s)
105
+ except (json.JSONDecodeError, ValueError):
106
+ if json5 is None:
107
+ raise
108
+ return json5.loads(s)
109
+
110
+ # ------------------------------------------------- whitespace & comments
111
+
112
+ def _is_space(self, s: str, i: int) -> bool:
113
+ c = s[i]
114
+ return c.isspace() or c in _JSON5_WHITESPACE
115
+
116
+ def _skip_space(self, s: str, i: int) -> int:
117
+ n = len(s)
118
+ while i < n:
119
+ c = s[i]
120
+ if c.isspace() or c in _JSON5_WHITESPACE:
121
+ i += 1
122
+ elif c == "/":
123
+ if i + 1 >= n:
124
+ return n # a comment that has only streamed its first '/'
125
+ nxt = s[i + 1]
126
+ if nxt == "/":
127
+ i += 2
128
+ while i < n and s[i] not in _LINE_TERMINATORS:
129
+ i += 1
130
+ elif nxt == "*":
131
+ end = s.find("*/", i + 2)
132
+ if end == -1:
133
+ return n # unterminated block comment swallows the rest
134
+ i = end + 2
135
+ else:
136
+ break
137
+ else:
138
+ break
139
+ return i
140
+
141
+ # --------------------------------------------------------------- values
142
+
143
+ def _scan_value(self, s: str, i: int, e: BaseException) -> ScanResult:
144
+ c = s[i]
145
+ if c == "'":
146
+ return self._scan_string(s, i, e)
147
+ if c in "tT":
148
+ return self._scan_literal(s, i, "true", True, e)
149
+ if c in "fF":
150
+ return self._scan_literal(s, i, "false", False, e)
151
+ if c == "n":
152
+ return self._scan_literal(s, i, "null", None, e)
153
+ if c == "N":
154
+ return self._scan_n_literal(s, i, e)
155
+ if c in "+I":
156
+ return self._scan_number(s, i, e)
157
+ return super()._scan_value(s, i, e)
158
+
159
+ def _scan_key(self, s: str, i: int, e: BaseException) -> ScanResult:
160
+ if s[i] in "\"'":
161
+ return self._scan_string(s, i, e)
162
+ n = len(s)
163
+ j = i
164
+ while j < n and (s[j].isalnum() or s[j] in "_$"):
165
+ j += 1
166
+ if j == i:
167
+ return _NO_KEY, i
168
+ return s[i:j], j
169
+
170
+ # -------------------------------------------------------------- strings
171
+
172
+ def _scan_extra_escape(self, s: str, j: int, n: int) -> Tuple[bool, int]:
173
+ esc = s[j + 1]
174
+ if esc == "x":
175
+ hex2 = s[j + 2 : j + 4]
176
+ if all(h in _HEX for h in hex2):
177
+ if len(hex2) < 2:
178
+ return True, 0 # \x or \xA at the end of the input
179
+ return False, 4
180
+ return False, 2
181
+ if esc == "\r" and s[j + 2 : j + 3] == "\n":
182
+ return False, 3
183
+ return False, 2
184
+
185
+ def _decode_incomplete(self, quote: str, content: str) -> Any:
186
+ try:
187
+ return _decode_json5_string(content)
188
+ except ValueError:
189
+ return ""
190
+
191
+ def _decode_complete(self, literal: str, quote: str) -> Any:
192
+ try:
193
+ return _decode_json5_string(literal[1:-1])
194
+ except ValueError:
195
+ return literal[1:-1]
196
+
197
+ # -------------------------------------------------------------- numbers
198
+
199
+ def _scan_number(self, s: str, i: int, e: BaseException) -> ScanResult:
200
+ n = len(s)
201
+ sign = 1
202
+ j = i
203
+ if s[j] in "+-":
204
+ sign = -1 if s[j] == "-" else 1
205
+ j += 1
206
+ if s[j : j + 2] in ("0x", "0X"):
207
+ k = j + 2
208
+ while k < n and s[k] in _HEX:
209
+ k += 1
210
+ if k == j + 2:
211
+ return s[i:k], n # "0x" with no digits yet
212
+ return sign * int(s[j + 2 : k], 16), k
213
+ for word, value in (("Infinity", float("inf")), ("NaN", float("nan"))):
214
+ k = self._literal_matches(s, j, word)
215
+ if k and (k == len(word) or j + k >= n):
216
+ return sign * value, j + k
217
+ if s[i] == "+":
218
+ if j >= n:
219
+ return "+", n
220
+ return super()._scan_number(s, j, e)
221
+ return super()._scan_number(s, i, e)
222
+
223
+ # ------------------------------------------------------------- literals
224
+
225
+ def _literal_matches(self, s: str, i: int, word: str) -> int:
226
+ n = len(s)
227
+ k = 0
228
+ while i + k < n and k < len(word) and s[i + k].lower() == word[k].lower():
229
+ k += 1
230
+ return k
231
+
232
+ def _scan_n_literal(self, s: str, i: int, e: BaseException) -> ScanResult:
233
+ if s[i + 1 : i + 2].lower() == "a":
234
+ return self._scan_number(s, i, e)
235
+ return self._scan_literal(s, i, "null", None, e)