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.
- partialjson-1.2.0/CHANGELOG.md +117 -0
- partialjson-1.2.0/CITATION.cff +13 -0
- partialjson-1.2.0/MANIFEST.in +3 -0
- partialjson-1.2.0/PKG-INFO +153 -0
- partialjson-1.0.0/PKG-INFO → partialjson-1.2.0/README.md +46 -25
- partialjson-1.2.0/partialjson/__init__.py +24 -0
- partialjson-1.2.0/partialjson/json5_parser.py +235 -0
- partialjson-1.2.0/partialjson/json_parser.py +403 -0
- partialjson-1.2.0/partialjson/py.typed +0 -0
- partialjson-1.2.0/partialjson.egg-info/PKG-INFO +153 -0
- partialjson-1.2.0/partialjson.egg-info/SOURCES.txt +21 -0
- partialjson-1.2.0/partialjson.egg-info/requires.txt +3 -0
- partialjson-1.2.0/pyproject.toml +61 -0
- partialjson-1.2.0/tests/fuzz_compat_1_1_0.py +81 -0
- partialjson-1.2.0/tests/legacy_1_1_0_parser.py +236 -0
- partialjson-1.2.0/tests/test_compat_1_1_0.py +127 -0
- partialjson-1.2.0/tests/test_json5.py +58 -0
- {partialjson-1.0.0 → partialjson-1.2.0}/tests/test_parser.py +7 -6
- partialjson-1.2.0/tests/test_regressions.py +251 -0
- partialjson-1.0.0/README.md +0 -79
- partialjson-1.0.0/partialjson/__init__.py +0 -20
- partialjson-1.0.0/partialjson/json_parser.py +0 -168
- partialjson-1.0.0/partialjson.egg-info/PKG-INFO +0 -98
- partialjson-1.0.0/partialjson.egg-info/SOURCES.txt +0 -10
- partialjson-1.0.0/setup.py +0 -34
- {partialjson-1.0.0 → partialjson-1.2.0}/LICENSE +0 -0
- {partialjson-1.0.0 → partialjson-1.2.0}/partialjson.egg-info/dependency_links.txt +0 -0
- {partialjson-1.0.0 → partialjson-1.2.0}/partialjson.egg-info/top_level.txt +0 -0
- {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,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
|
+
[](https://pypi.org/project/partialjson/)
|
|
38
|
+
|
|
39
|
+
## Parse Partial and incomplete JSON in python
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
### Parse Partial and incomplete JSON in python with just 3 lines of python code.
|
|
44
|
+
|
|
45
|
+
[](https://pypi.org/project/partialjson)
|
|
46
|
+
[](#Installation)
|
|
47
|
+
[](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
|
+
[](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
|
[](https://pypi.org/project/partialjson/)
|
|
@@ -34,18 +15,18 @@ Dynamic: summary
|
|
|
34
15
|
## Example
|
|
35
16
|
|
|
36
17
|
```python
|
|
37
|
-
from partialjson
|
|
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
|
|
26
|
+
Problem with `\n`? Use `strict=False`:
|
|
46
27
|
|
|
47
28
|
```python
|
|
48
|
-
from partialjson
|
|
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
|
|
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)
|