From 3e60997edba46544557b3a775bdb1538e07c3edf Mon Sep 17 00:00:00 2001 From: Zach White Date: Thu, 25 Mar 2021 04:38:10 -0700 Subject: [PATCH] Add a `qmk format-json` command that will format JSON files (#12372) * Add a command to format json files * change to work after rebase * add test for qmk format-json * add documentation for qmk format-json * Update lib/python/qmk/cli/format/json.py --- docs/cli_commands.md | 10 ++ lib/python/qmk/cli/__init__.py | 1 + lib/python/qmk/cli/c2json.py | 2 +- lib/python/qmk/cli/format/__init__.py | 1 + lib/python/qmk/cli/format/json.py | 66 ++++++++ lib/python/qmk/cli/generate/api.py | 2 +- lib/python/qmk/cli/generate/info_json.py | 2 +- lib/python/qmk/cli/info.py | 2 +- lib/python/qmk/cli/kle2json.py | 2 +- lib/python/qmk/info_json_encoder.py | 96 ----------- lib/python/qmk/json_encoders.py | 192 ++++++++++++++++++++++ lib/python/qmk/tests/minimal_info.json | 13 ++ lib/python/qmk/tests/minimal_keymap.json | 7 + lib/python/qmk/tests/pytest_export.json | 6 - lib/python/qmk/tests/test_cli_commands.py | 24 +++ 15 files changed, 319 insertions(+), 107 deletions(-) create mode 100644 lib/python/qmk/cli/format/__init__.py create mode 100755 lib/python/qmk/cli/format/json.py delete mode 100755 lib/python/qmk/info_json_encoder.py create mode 100755 lib/python/qmk/json_encoders.py create mode 100644 lib/python/qmk/tests/minimal_info.json create mode 100644 lib/python/qmk/tests/minimal_keymap.json delete mode 100644 lib/python/qmk/tests/pytest_export.json diff --git a/docs/cli_commands.md b/docs/cli_commands.md index bb5df89968..6498b28b88 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -131,6 +131,16 @@ Check your environment and report problems only: qmk doctor -n +## `qmk format-json` + +Formats a JSON file in a (mostly) human-friendly way. Will usually correctly detect the format of the JSON (info.json or keymap.json) but you can override this with `--format` if neccesary. + +**Usage**: + +``` +qmk format-json [-f FORMAT] +``` + ## `qmk info` Displays information about keyboards and keymaps in QMK. You can use this to get information about a keyboard, show the layouts, display the underlying key matrix, or to pretty-print JSON keymaps. diff --git a/lib/python/qmk/cli/__init__.py b/lib/python/qmk/cli/__init__.py index a5f1f47679..1349e68a9b 100644 --- a/lib/python/qmk/cli/__init__.py +++ b/lib/python/qmk/cli/__init__.py @@ -16,6 +16,7 @@ from . import docs from . import doctor from . import fileformat from . import flash +from . import format from . import generate from . import hello from . import info diff --git a/lib/python/qmk/cli/c2json.py b/lib/python/qmk/cli/c2json.py index a97e212223..1fa833b647 100644 --- a/lib/python/qmk/cli/c2json.py +++ b/lib/python/qmk/cli/c2json.py @@ -6,7 +6,7 @@ from milc import cli import qmk.keymap import qmk.path -from qmk.info_json_encoder import InfoJSONEncoder +from qmk.json_encoders import InfoJSONEncoder from qmk.keyboard import keyboard_folder diff --git a/lib/python/qmk/cli/format/__init__.py b/lib/python/qmk/cli/format/__init__.py new file mode 100644 index 0000000000..741ec778b1 --- /dev/null +++ b/lib/python/qmk/cli/format/__init__.py @@ -0,0 +1 @@ +from . import json diff --git a/lib/python/qmk/cli/format/json.py b/lib/python/qmk/cli/format/json.py new file mode 100755 index 0000000000..1358c70e7a --- /dev/null +++ b/lib/python/qmk/cli/format/json.py @@ -0,0 +1,66 @@ +"""JSON Formatting Script + +Spits out a JSON file formatted with one of QMK's formatters. +""" +import json + +from jsonschema import ValidationError +from milc import cli + +from qmk.info import info_json +from qmk.json_schema import json_load, keyboard_validate +from qmk.json_encoders import InfoJSONEncoder, KeymapJSONEncoder +from qmk.path import normpath + + +@cli.argument('json_file', arg_only=True, type=normpath, help='JSON file to format') +@cli.argument('-f', '--format', choices=['auto', 'keyboard', 'keymap'], default='auto', arg_only=True, help='JSON formatter to use (Default: autodetect)') +@cli.subcommand('Generate an info.json file for a keyboard.', hidden=False if cli.config.user.developer else True) +def format_json(cli): + """Format a json file. + """ + json_file = json_load(cli.args.json_file) + + if cli.args.format == 'auto': + try: + keyboard_validate(json_file) + json_encoder = InfoJSONEncoder + + except ValidationError as e: + cli.log.warning('File %s did not validate as a keyboard:\n\t%s', cli.args.json_file, e) + cli.log.info('Treating %s as a keymap file.', cli.args.json_file) + json_encoder = KeymapJSONEncoder + + elif cli.args.format == 'keyboard': + json_encoder = InfoJSONEncoder + elif cli.args.format == 'keymap': + json_encoder = KeymapJSONEncoder + else: + # This should be impossible + cli.log.error('Unknown format: %s', cli.args.format) + return False + + if json_encoder == KeymapJSONEncoder and 'layout' in json_file: + # Attempt to format the keycodes. + layout = json_file['layout'] + info_data = info_json(json_file['keyboard']) + + if layout in info_data.get('layout_aliases', {}): + layout = json_file['layout'] = info_data['layout_aliases'][layout] + + if layout in info_data.get('layouts'): + for layer_num, layer in enumerate(json_file['layers']): + current_layer = [] + last_row = 0 + + for keymap_key, info_key in zip(layer, info_data['layouts'][layout]['layout']): + if last_row != info_key['y']: + current_layer.append('JSON_NEWLINE') + last_row = info_key['y'] + + current_layer.append(keymap_key) + + json_file['layers'][layer_num] = current_layer + + # Display the results + print(json.dumps(json_file, cls=json_encoder)) diff --git a/lib/python/qmk/cli/generate/api.py b/lib/python/qmk/cli/generate/api.py index 2905908345..70019428f0 100755 --- a/lib/python/qmk/cli/generate/api.py +++ b/lib/python/qmk/cli/generate/api.py @@ -8,7 +8,7 @@ from milc import cli from qmk.datetime import current_datetime from qmk.info import info_json -from qmk.info_json_encoder import InfoJSONEncoder +from qmk.json_encoders import InfoJSONEncoder from qmk.json_schema import json_load from qmk.keyboard import list_keyboards diff --git a/lib/python/qmk/cli/generate/info_json.py b/lib/python/qmk/cli/generate/info_json.py index 6c00ba7d8a..1af7f04392 100755 --- a/lib/python/qmk/cli/generate/info_json.py +++ b/lib/python/qmk/cli/generate/info_json.py @@ -9,7 +9,7 @@ from milc import cli from qmk.decorators import automagic_keyboard, automagic_keymap from qmk.info import info_json -from qmk.info_json_encoder import InfoJSONEncoder +from qmk.json_encoders import InfoJSONEncoder from qmk.json_schema import load_jsonschema from qmk.keyboard import keyboard_folder from qmk.path import is_keyboard diff --git a/lib/python/qmk/cli/info.py b/lib/python/qmk/cli/info.py index 88b65686f5..aac507c1a5 100755 --- a/lib/python/qmk/cli/info.py +++ b/lib/python/qmk/cli/info.py @@ -7,7 +7,7 @@ import platform from milc import cli -from qmk.info_json_encoder import InfoJSONEncoder +from qmk.json_encoders import InfoJSONEncoder from qmk.constants import COL_LETTERS, ROW_LETTERS from qmk.decorators import automagic_keyboard, automagic_keymap from qmk.keyboard import keyboard_folder, render_layouts, render_layout diff --git a/lib/python/qmk/cli/kle2json.py b/lib/python/qmk/cli/kle2json.py index 3bb7443582..91499c9af3 100755 --- a/lib/python/qmk/cli/kle2json.py +++ b/lib/python/qmk/cli/kle2json.py @@ -8,7 +8,7 @@ from milc import cli from kle2xy import KLE2xy from qmk.converter import kle2qmk -from qmk.info_json_encoder import InfoJSONEncoder +from qmk.json_encoders import InfoJSONEncoder @cli.argument('filename', help='The KLE raw txt to convert') diff --git a/lib/python/qmk/info_json_encoder.py b/lib/python/qmk/info_json_encoder.py deleted file mode 100755 index 60dae7247f..0000000000 --- a/lib/python/qmk/info_json_encoder.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Class that pretty-prints QMK info.json files. -""" -import json -from decimal import Decimal - - -class InfoJSONEncoder(json.JSONEncoder): - """Custom encoder to make info.json's a little nicer to work with. - """ - container_types = (list, tuple, dict) - indentation_char = " " - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.indentation_level = 0 - - if not self.indent: - self.indent = 4 - - def encode(self, obj): - """Encode JSON objects for QMK. - """ - if isinstance(obj, Decimal): - if obj == int(obj): # I can't believe Decimal objects don't have .is_integer() - return int(obj) - return float(obj) - - elif isinstance(obj, (list, tuple)): - if self._primitives_only(obj): - return "[" + ", ".join(self.encode(element) for element in obj) + "]" - - else: - self.indentation_level += 1 - output = [self.indent_str + self.encode(element) for element in obj] - self.indentation_level -= 1 - return "[\n" + ",\n".join(output) + "\n" + self.indent_str + "]" - - elif isinstance(obj, dict): - if obj: - if self.indentation_level == 4: - # These are part of a layout, put them on a single line. - return "{ " + ", ".join(f"{self.encode(key)}: {self.encode(element)}" for key, element in sorted(obj.items())) + " }" - - else: - self.indentation_level += 1 - output = [self.indent_str + f"{json.dumps(key)}: {self.encode(value)}" for key, value in sorted(obj.items(), key=self.sort_root_dict)] - self.indentation_level -= 1 - return "{\n" + ",\n".join(output) + "\n" + self.indent_str + "}" - else: - return "{}" - else: - return super().encode(obj) - - def _primitives_only(self, obj): - """Returns true if the object doesn't have any container type objects (list, tuple, dict). - """ - if isinstance(obj, dict): - obj = obj.values() - - return not any(isinstance(element, self.container_types) for element in obj) - - def sort_root_dict(self, key): - """Forces layout to the back of the sort order. - """ - key = key[0] - - if self.indentation_level == 1: - if key == 'manufacturer': - return '10keyboard_name' - - elif key == 'keyboard_name': - return '11keyboard_name' - - elif key == 'maintainer': - return '12maintainer' - - elif key in ('height', 'width'): - return '40' + str(key) - - elif key == 'community_layouts': - return '97community_layouts' - - elif key == 'layout_aliases': - return '98layout_aliases' - - elif key == 'layouts': - return '99layouts' - - else: - return '50' + str(key) - - return key - - @property - def indent_str(self): - return self.indentation_char * (self.indentation_level * self.indent) diff --git a/lib/python/qmk/json_encoders.py b/lib/python/qmk/json_encoders.py new file mode 100755 index 0000000000..9f3da022b4 --- /dev/null +++ b/lib/python/qmk/json_encoders.py @@ -0,0 +1,192 @@ +"""Class that pretty-prints QMK info.json files. +""" +import json +from decimal import Decimal + +newline = '\n' + + +class QMKJSONEncoder(json.JSONEncoder): + """Base class for all QMK JSON encoders. + """ + container_types = (list, tuple, dict) + indentation_char = " " + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.indentation_level = 0 + + if not self.indent: + self.indent = 4 + + def encode_decimal(self, obj): + """Encode a decimal object. + """ + if obj == int(obj): # I can't believe Decimal objects don't have .is_integer() + return int(obj) + + return float(obj) + + def encode_list(self, obj): + """Encode a list-like object. + """ + if self.primitives_only(obj): + return "[" + ", ".join(self.encode(element) for element in obj) + "]" + + else: + self.indentation_level += 1 + output = [self.indent_str + self.encode(element) for element in obj] + self.indentation_level -= 1 + + return "[\n" + ",\n".join(output) + "\n" + self.indent_str + "]" + + def encode(self, obj): + """Encode keymap.json objects for QMK. + """ + if isinstance(obj, Decimal): + return self.encode_decimal(obj) + + elif isinstance(obj, (list, tuple)): + return self.encode_list(obj) + + elif isinstance(obj, dict): + return self.encode_dict(obj) + + else: + return super().encode(obj) + + def primitives_only(self, obj): + """Returns true if the object doesn't have any container type objects (list, tuple, dict). + """ + if isinstance(obj, dict): + obj = obj.values() + + return not any(isinstance(element, self.container_types) for element in obj) + + @property + def indent_str(self): + return self.indentation_char * (self.indentation_level * self.indent) + + +class InfoJSONEncoder(QMKJSONEncoder): + """Custom encoder to make info.json's a little nicer to work with. + """ + def encode_dict(self, obj): + """Encode info.json dictionaries. + """ + if obj: + if self.indentation_level == 4: + # These are part of a layout, put them on a single line. + return "{ " + ", ".join(f"{self.encode(key)}: {self.encode(element)}" for key, element in sorted(obj.items())) + " }" + + else: + self.indentation_level += 1 + output = [self.indent_str + f"{json.dumps(key)}: {self.encode(value)}" for key, value in sorted(obj.items(), key=self.sort_dict)] + self.indentation_level -= 1 + return "{\n" + ",\n".join(output) + "\n" + self.indent_str + "}" + else: + return "{}" + + def sort_dict(self, key): + """Forces layout to the back of the sort order. + """ + key = key[0] + + if self.indentation_level == 1: + if key == 'manufacturer': + return '10keyboard_name' + + elif key == 'keyboard_name': + return '11keyboard_name' + + elif key == 'maintainer': + return '12maintainer' + + elif key in ('height', 'width'): + return '40' + str(key) + + elif key == 'community_layouts': + return '97community_layouts' + + elif key == 'layout_aliases': + return '98layout_aliases' + + elif key == 'layouts': + return '99layouts' + + else: + return '50' + str(key) + + return key + + +class KeymapJSONEncoder(QMKJSONEncoder): + """Custom encoder to make keymap.json's a little nicer to work with. + """ + def encode_dict(self, obj): + """Encode dictionary objects for keymap.json. + """ + if obj: + self.indentation_level += 1 + output_lines = [f"{self.indent_str}{json.dumps(key)}: {self.encode(value)}" for key, value in sorted(obj.items(), key=self.sort_dict)] + output = ',\n'.join(output_lines) + self.indentation_level -= 1 + + return f"{{\n{output}\n{self.indent_str}}}" + + else: + return "{}" + + def encode_list(self, obj): + """Encode a list-like object. + """ + if self.indentation_level == 2: + indent_level = self.indentation_level + 1 + # We have a list of keycodes + layer = [[]] + + for key in obj: + if key == 'JSON_NEWLINE': + layer.append([]) + else: + layer[-1].append(f'"{key}"') + + layer = [f"{self.indent_str*indent_level}{', '.join(row)}" for row in layer] + + return f"{self.indent_str}[\n{newline.join(layer)}\n{self.indent_str*self.indentation_level}]" + + elif self.primitives_only(obj): + return "[" + ", ".join(self.encode(element) for element in obj) + "]" + + else: + self.indentation_level += 1 + output = [self.indent_str + self.encode(element) for element in obj] + self.indentation_level -= 1 + + return "[\n" + ",\n".join(output) + "\n" + self.indent_str + "]" + + def sort_dict(self, key): + """Sorts the hashes in a nice way. + """ + key = key[0] + + if self.indentation_level == 1: + if key == 'version': + return '00version' + + elif key == 'author': + return '01author' + + elif key == 'notes': + return '02notes' + + elif key == 'layers': + return '98layers' + + elif key == 'documentation': + return '99documentation' + + else: + return '50' + str(key) + + return key diff --git a/lib/python/qmk/tests/minimal_info.json b/lib/python/qmk/tests/minimal_info.json new file mode 100644 index 0000000000..b91c23bd3d --- /dev/null +++ b/lib/python/qmk/tests/minimal_info.json @@ -0,0 +1,13 @@ +{ + "keyboard_name": "tester", + "maintainer": "qmk", + "height": 5, + "width": 15, + "layouts": { + "LAYOUT": { + "layout": [ + { "label": "KC_A", "x": 0, "y": 0, "matrix": [0, 0] } + ] + } + } +} diff --git a/lib/python/qmk/tests/minimal_keymap.json b/lib/python/qmk/tests/minimal_keymap.json new file mode 100644 index 0000000000..258f9e8a9a --- /dev/null +++ b/lib/python/qmk/tests/minimal_keymap.json @@ -0,0 +1,7 @@ +{ + "keyboard": "handwired/pytest/basic", + "keymap": "test", + "layers": [["KC_A"]], + "layout": "LAYOUT_ortho_1x1", + "version": 1 +} diff --git a/lib/python/qmk/tests/pytest_export.json b/lib/python/qmk/tests/pytest_export.json deleted file mode 100644 index 5fb0d624f8..0000000000 --- a/lib/python/qmk/tests/pytest_export.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "keyboard":"handwired/pytest/basic", - "keymap":"pytest_unittest", - "layout":"LAYOUT", - "layers":[["KC_A"]] -} diff --git a/lib/python/qmk/tests/test_cli_commands.py b/lib/python/qmk/tests/test_cli_commands.py index a97472e6be..c57d2b7fc7 100644 --- a/lib/python/qmk/tests/test_cli_commands.py +++ b/lib/python/qmk/tests/test_cli_commands.py @@ -259,3 +259,27 @@ def test_generate_layouts(): result = check_subcommand('generate-layouts', '-kb', 'handwired/pytest/basic') check_returncode(result) assert '#define LAYOUT_custom(k0A) {' in result.stdout + + +def test_format_json_keyboard(): + result = check_subcommand('format-json', '--format', 'keyboard', 'lib/python/qmk/tests/minimal_info.json') + check_returncode(result) + assert result.stdout == '{\n "keyboard_name": "tester",\n "maintainer": "qmk",\n "height": 5,\n "width": 15,\n "layouts": {\n "LAYOUT": {\n "layout": [\n { "label": "KC_A", "matrix": [0, 0], "x": 0, "y": 0 }\n ]\n }\n }\n}\n' + + +def test_format_json_keymap(): + result = check_subcommand('format-json', '--format', 'keymap', 'lib/python/qmk/tests/minimal_keymap.json') + check_returncode(result) + assert result.stdout == '{\n "version": 1,\n "keyboard": "handwired/pytest/basic",\n "keymap": "test",\n "layout": "LAYOUT_ortho_1x1",\n "layers": [\n [\n "KC_A"\n ]\n ]\n}\n' + + +def test_format_json_keyboard_auto(): + result = check_subcommand('format-json', '--format', 'auto', 'lib/python/qmk/tests/minimal_info.json') + check_returncode(result) + assert result.stdout == '{\n "keyboard_name": "tester",\n "maintainer": "qmk",\n "height": 5,\n "width": 15,\n "layouts": {\n "LAYOUT": {\n "layout": [\n { "label": "KC_A", "matrix": [0, 0], "x": 0, "y": 0 }\n ]\n }\n }\n}\n' + + +def test_format_json_keymap_auto(): + result = check_subcommand('format-json', '--format', 'auto', 'lib/python/qmk/tests/minimal_keymap.json') + check_returncode(result) + assert result.stdout == '{\n "keyboard": "handwired/pytest/basic",\n "keymap": "test",\n "layers": [\n ["KC_A"]\n ],\n "layout": "LAYOUT_ortho_1x1",\n "version": 1\n}\n'