Skip to content

Commit e908a97

Browse files
authored
Merge pull request #7 from seamile/dev
change the query language from JsonPath to JmesPath.
2 parents 0b31a13 + 3453764 commit e908a97

File tree

5 files changed

+222
-138
lines changed

5 files changed

+222
-138
lines changed

README.md

Lines changed: 84 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# JSON Formator
1+
# JSON Formatter
22

33
[![Build Status](https://github.com/seamile/jsonfmt/actions/workflows/python-package.yml/badge.svg)](https://github.com/seamile/jsonfmt/actions)
44
[![PyPI Version](https://img.shields.io/pypi/v/jsonfmt?color=blue&label=Version&logo=python&logoColor=white)](https://pypi.org/project/jsonfmt/)
@@ -8,7 +8,8 @@
88

99
**jsonfmt** is a powerful tool for handling JSON document.
1010

11-
It is similar to [jq](https://github.com/jqlang/jq), but simpler.
11+
It is as powerful as [jq](https://github.com/jqlang/jq), but simpler.
12+
1213

1314
## Features
1415

@@ -20,7 +21,7 @@ It is similar to [jq](https://github.com/jqlang/jq), but simpler.
2021
- [Show the overview of a large JSON.](#show-the-overview-of-a-large-json)
2122
- [Copy the result to clipboard.](#copy-the-result-to-clipboard)
2223
- [3. Minimize the JSON document.](#3-minimize-the-json-document)
23-
- [4. Pick out parts of a large JSON via JSONPath.](#4-pick-out-parts-of-a-large-json-via-jsonpath)
24+
- [4. Pick out parts of a large JSON via JmesPath.](#4-pick-out-parts-of-a-large-json-via-jmespath)
2425
- [5. Convert formats between JSON, TOML and YAML.](#5-convert-formats-between-json-toml-and-yaml)
2526
- [JSON to TOML and YAML](#json-to-toml-and-yaml)
2627
- [TOML to JSON and YAML](#toml-to-json-and-yaml)
@@ -58,7 +59,7 @@ $ jsonfmt [options] [files ...]
5859
- `-i {0-8,t}`: number of spaces for indentation (default: 2)
5960
- `-o`: show data structure overview
6061
- `-O`: overwrite the formated text to original file
61-
- `-p JSONPATH`: output part of the object via jsonpath
62+
- `-p JSONPATH`: output part of the object via jmespath
6263
- `-s`: sort keys of objects on output
6364
- `--set 'foo.k1=v1;k2[i]=v2'`: set the keys to values (seperated by `;`)
6465
- `--pop 'k1;foo.k2;k3[i]'`: pop the specified keys (seperated by `;`)
@@ -213,13 +214,15 @@ $ echo '{
213214
{"age":21,"items":["pen","phone"],"name":"alex"}
214215
```
215216

216-
### 4. Pick out parts of a large JSON via JSONPath.
217+
### 4. Pick out parts of a large JSON via JmesPath.
217218

218-
**JSONPath** is a way to query the sub-elements of a JSON document.
219+
Unlike from jq's private solution, `jsonfmt` uses [JmesPath](https://jmespath.org/) as its query language.
219220

220-
It likes the XPath for xml, which can extract part of the content of a given JSON document through a simple syntax.
221+
Among the many JSON query languages, `JmesPath` is the most popular one ([compared here](https://npmtrends.com/JSONPath-vs-jmespath-vs-jq-vs-json-path-vs-json-query-vs-jsonata-vs-jsonpath-vs-jsonpath-plus-vs-node-jq)).
222+
It is more general than `jq`, and more intuitive and powerful than `JsonPath`.
221223

222-
JSONPath syntax reference: [goessner.net](https://goessner.net/articles/JsonPath/), [ietf.org](https://datatracker.ietf.org/doc/id/draft-goessner-dispatch-jsonpath-00.html).
224+
Like the XPath for xml, `JmesPath` can elegantly extract parts of a given JSON document with simple syntax.
225+
See the tutorial [here](https://jmespath.org/tutorial.html).
223226

224227
Some examples:
225228

@@ -231,6 +234,22 @@ Some examples:
231234

232235
*Output:*
233236

237+
```json
238+
{
239+
"calorie": 294.9,
240+
"date": "2021-03-02",
241+
"name": "eat"
242+
}
243+
```
244+
245+
- Filter all items in `actions` with `calorie` > 0.
246+
247+
```shell
248+
$ jsonfmt -p 'actions[?calorie>`0`]' test/example.json
249+
```
250+
251+
*Output:*
252+
234253
```json
235254
[
236255
{
@@ -241,22 +260,71 @@ Some examples:
241260
]
242261
```
243262

244-
- Filters all occurrences of the `name` field in the JSON.
263+
- Show all the keys and actions' length.
264+
265+
```shell
266+
$ jsonfmt -p '{all_keys:keys(@), actions_len:length(actions)}' test/example.json
267+
```
268+
269+
*Output:*
270+
271+
```json
272+
{
273+
"all_keys": [
274+
"actions",
275+
"age",
276+
"gender",
277+
"money",
278+
"name"
279+
],
280+
"actions_len": 2
281+
}
282+
```
283+
284+
- Sort `actions` by `calorie` and redefine a dict.
245285
246286
```shell
247-
$ jsonfmt -p '$..name' test/example.json
287+
$ jsonfmt -p 'sort_by(actions, &calorie)[].{name: name, calorie:calorie}' test/example.json
248288
```
249289
250290
*Output:*
251291
252292
```json
253293
[
254-
"Bob",
255-
"eat",
256-
"sport"
294+
{
295+
"name": "sport",
296+
"calorie": -375
297+
},
298+
{
299+
"name": "eat",
300+
"calorie": 294.9
301+
}
257302
]
258303
```
259304
305+
- [More examples](https://jmespath.org/examples.html).
306+
307+
308+
**Amazingly**, you can do the same with YAML and TOML using JmesPath, and convert the result format arbitrarily.
309+
310+
```shell
311+
# read the data from toml file, and convert the result to yaml
312+
$ jsonfmt -p '{all_keys:keys(@), actions_len:length(actions)}' test/example.yaml -f toml
313+
```
314+
315+
*Output:*
316+
317+
```yaml
318+
all_keys:
319+
- age
320+
- gender
321+
- money
322+
- name
323+
- actions
324+
actions_len: 2
325+
```
326+
327+
260328
### 5. Convert formats between JSON, TOML and YAML.
261329
262330
The *jsonfmt* can recognize any format of JSON, TOML and YAML from files or `stdin`. Either formats can be converted to the other by specifying the "-f" option.
@@ -372,8 +440,8 @@ $ jsonfmt --set 'skills=["Django","Flask"];money=1000' test/example.json
372440
#### Pop some items.
373441

374442
```shell
375-
# remove the gender field and action[1]
376-
$ jsonfmt --pop 'gender;action[1]' test/example.json
443+
# remove the gender field and actions[1]
444+
$ jsonfmt --pop 'gender;actions[1]' test/example.json
377445
```
378446

379447
*Output:*
@@ -396,7 +464,7 @@ $ jsonfmt --pop 'gender;action[1]' test/example.json
396464
Of course you can use `--set` and `--pop` together.
397465

398466
```shell
399-
jsonfmt --set 'skills=["Django","Flask"];money=1000' --pop 'gender;action[1]' test/example.json
467+
jsonfmt --set 'skills=["Django","Flask"];money=1000' --pop 'gender;actions[1]' test/example.json
400468
```
401469

402470
**Note**, however, that the above command will not modify the original JSON file.

jsonfmt.py

Lines changed: 43 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,28 @@
11
#!/usr/bin/env python
2-
'''JSON Format Tool'''
2+
'''JSON Formatter'''
33

44
import json
5-
import pyperclip
65
import re
76
import toml
87
import yaml
98
from argparse import ArgumentParser
109
from functools import partial
1110
from io import TextIOBase
12-
from jsonpath import jsonpath
1311
from pydoc import pager
14-
from pygments import highlight
15-
from pygments.formatters import TerminalFormatter
16-
from pygments.lexers import JsonLexer, TOMLLexer, YamlLexer
1712
from shutil import get_terminal_size
18-
from sys import stdin, stdout, stderr
13+
from sys import stdin, stdout, stderr, exit as sys_exit
1914
from typing import Any, List, IO, Optional, Sequence, Tuple, Union
2015
from unittest.mock import patch
2116

22-
__version__ = '0.2.4'
17+
import jmespath
18+
import pyperclip
19+
from jmespath.exceptions import JMESPathError
20+
from jmespath.parser import ParsedResult
21+
from pygments import highlight
22+
from pygments.formatters import TerminalFormatter
23+
from pygments.lexers import JsonLexer, TOMLLexer, YamlLexer
24+
25+
__version__ = '0.2.5'
2326

2427
NUMERIC = re.compile(r'-?\d+$|-?\d+\.\d+$|^-?\d+\.?\d+e-?\d+$')
2528
DICT_OR_LIST = re.compile(r'^\{.*\}$|^\[.*\]$')
@@ -33,11 +36,7 @@ def print_err(msg: Any):
3336
print(f'\033[1;91mjsonfmt:\033[0m \033[0;91m{msg}\033[0m', file=stderr)
3437

3538

36-
class ParseError(Exception):
37-
pass
38-
39-
40-
class JsonPathError(Exception):
39+
class FormatError(Exception):
4140
pass
4241

4342

@@ -48,8 +47,8 @@ def is_clipboard_available() -> bool:
4847
and paste_fn.__class__.__name__ != 'ClipboardUnavailable'
4948

5049

51-
def parse_to_pyobj(text: str, jpath: Optional[str]) -> Tuple[Any, str]:
52-
'''read json, toml or yaml from IO and then match sub-element by jsonpath'''
50+
def parse_to_pyobj(text: str, jpath: Optional[ParsedResult]) -> Tuple[Any, str]:
51+
'''read json, toml or yaml from IO and then match sub-element by jmespath'''
5352
# parse json, toml or yaml to python object
5453
loads_methods = {
5554
'json': json.loads,
@@ -65,17 +64,13 @@ def parse_to_pyobj(text: str, jpath: Optional[str]) -> Tuple[Any, str]:
6564
except Exception:
6665
continue
6766
else:
68-
raise ParseError("no json, toml or yaml found in the text")
67+
raise FormatError("no json, toml or yaml found in the text")
6968

7069
if jpath is None:
7170
return py_obj, fmt
7271
else:
73-
# match sub-elements via jsonpath
74-
subelements = jsonpath(py_obj, jpath)
75-
if subelements is False:
76-
raise JsonPathError('invalid JSONPath or query result is empty')
77-
else:
78-
return subelements, fmt
72+
# match sub-elements via jmespath
73+
return jpath.search(py_obj), fmt
7974

8075

8176
def forward_by_keys(py_obj: Any, keys: str) -> Tuple[Any, Union[str, int]]:
@@ -120,20 +115,20 @@ def modify_pyobj(py_obj: Any, sets: List[str], pops: List[str]):
120115

121116

122117
def get_overview(py_obj: Any):
123-
def clip_value(value: Any):
118+
def clip(value: Any):
124119
if isinstance(value, str):
125120
return '...'
126121
elif isinstance(value, (list, tuple)):
127122
return []
128123
elif isinstance(value, dict):
129-
return {k: clip_value(v) for k, v in value.items()}
124+
return {k: clip(v) for k, v in value.items()}
130125
else:
131126
return value
132127

133-
if isinstance(py_obj, list):
134-
return [clip_value(py_obj[0])]
128+
if isinstance(py_obj, list) and len(py_obj) > 1:
129+
return [clip(py_obj[0])]
135130
else:
136-
return clip_value(py_obj)
131+
return clip(py_obj)
137132

138133

139134
def format_to_text(py_obj: Any, fmt: str, *,
@@ -151,7 +146,7 @@ def format_to_text(py_obj: Any, fmt: str, *,
151146
elif fmt == 'toml':
152147
if not isinstance(py_obj, dict):
153148
msg = 'the pyobj must be a Mapping when format to toml'
154-
raise ParseError(msg)
149+
raise FormatError(msg)
155150
return toml.dumps(py_obj)
156151

157152
elif fmt == 'yaml':
@@ -160,14 +155,14 @@ def format_to_text(py_obj: Any, fmt: str, *,
160155
sort_keys=sort_keys)
161156

162157
else:
163-
raise ParseError('Unknow format')
158+
raise FormatError('Unknow format')
164159

165160

166161
def output(output_fp: IO, text: str, fmt: str, cp2clip: bool):
167162
# copy the result to clipboard
168163
if cp2clip:
169164
pyperclip.copy(text)
170-
print_inf('result copied to clipboard.')
165+
print_inf('result copied to clipboard')
171166
return
172167
elif output_fp.isatty():
173168
# highlight the text when output to TTY divice
@@ -185,11 +180,11 @@ def output(output_fp: IO, text: str, fmt: str, cp2clip: bool):
185180
output_fp.truncate()
186181
output_fp.write(text)
187182
if output_fp.fileno() > 2:
188-
print_inf(f'result written to {output_fp.name}.')
183+
print_inf(f'result written to {output_fp.name}')
189184

190185

191-
def process(input_fp: IO, jpath: Optional[str], convert_fmt: Optional[str], *,
192-
compact: bool, cp2clip: bool, escape: bool, indent: Union[int, str],
186+
def process(input_fp: IO, jpath: Optional[ParsedResult], convert_fmt: Optional[str],
187+
*, compact: bool, cp2clip: bool, escape: bool, indent: Union[int, str],
193188
overview: bool, overwrite: bool, sort_keys: bool,
194189
sets: Optional[list], pops: Optional[list]):
195190
# parse and format
@@ -233,8 +228,8 @@ def parse_cmdline_args(args: Optional[Sequence[str]] = None):
233228
help='show data structure overview')
234229
parser.add_argument('-O', dest='overwrite', action='store_true',
235230
help='overwrite the formated text to original file')
236-
parser.add_argument('-p', dest='jsonpath', type=str,
237-
help='output part of the object via jsonpath')
231+
parser.add_argument('-p', dest='jmespath', type=str,
232+
help='output part of the object via jmespath')
238233
parser.add_argument('-s', dest='sort_keys', action='store_true',
239234
help='sort keys of objects on output')
240235
parser.add_argument('--set', metavar="'foo.k1=v1;k2[i]=v2'",
@@ -251,6 +246,12 @@ def parse_cmdline_args(args: Optional[Sequence[str]] = None):
251246
def main():
252247
args = parse_cmdline_args()
253248

249+
try:
250+
jpath = None if args.jmespath is None else jmespath.compile(args.jmespath)
251+
except JMESPathError:
252+
print_err(f'invalid JMESPath expression: {args.jmespath}')
253+
sys_exit(1)
254+
254255
# check if the clipboard is available
255256
cp2clip = args.cp2clip and is_clipboard_available()
256257
if args.cp2clip and not cp2clip:
@@ -273,7 +274,7 @@ def main():
273274
# read from file
274275
input_fp = open(file, 'r+') if isinstance(file, str) else file
275276
process(input_fp,
276-
args.jsonpath,
277+
jpath,
277278
args.format,
278279
compact=args.compact,
279280
cp2clip=cp2clip,
@@ -284,10 +285,14 @@ def main():
284285
sort_keys=args.sort_keys,
285286
sets=sets,
286287
pops=pops)
287-
except ParseError as err:
288+
except FormatError as err:
289+
print_err(err)
290+
except JMESPathError as err:
288291
print_err(err)
289292
except FileNotFoundError:
290-
print_err(f'no such file `{file}`')
293+
print_err(f'no such file: {file}')
294+
except PermissionError:
295+
print_err(f'permission denied: {file}')
291296
finally:
292297
input_fp = locals().get('input_fp')
293298
if isinstance(input_fp, TextIOBase):

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ requires-python = ">=3.8"
99
license = {text = "MIT License"}
1010
description = "A simple tool for formatting JSON object."
1111
readme = "README.md"
12-
keywords = ["json", "formatter", "pretty-print", "highlight", "jsonpath"]
12+
keywords = ["json", "formatter", "pretty-print", "highlight", "jmespath"]
1313
authors = [{name = "Seamile", email = "[email protected]"}]
1414
dependencies = [
15-
"jsonpath==0.82",
15+
"jmespath >= 1.0.1",
1616
"Pygments >= 2.13.0",
1717
"pyperclip >= 1.8.2",
1818
"pyyaml >= 6.0",

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
jsonpath==0.82
1+
jmespath >= 1.0.1
22
Pygments >= 2.13.0
33
pyperclip >= 1.8.2
44
pyyaml >= 6.0

0 commit comments

Comments
 (0)