Skip to content

Commit

Permalink
Add support for jsonc files (#40)
Browse files Browse the repository at this point in the history
* Add support for .jsonc in GUI
* Accept either json or jsonc input
* Generate pure json output when json is selected
  • Loading branch information
sveinse authored Sep 11, 2024
1 parent 22153bc commit 0b8cc2d
Show file tree
Hide file tree
Showing 10 changed files with 124 additions and 43 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ _tmp*
tests/od/extra-compare*
/*.od
/*.json
/*.jsonc

objdictgen.*
fw-can-shared
Expand Down
17 changes: 7 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ Laerdal Medical fork for the canfestival library:
objdictgen is a tool to parse, view and manipulate files containing object
dictionary (OD). An object dictionary is entries with data and configuration
in CANopen devices. The `odg` executable is installed. It supports
reading and writing OD files in `.json` format, in legacy XML `.od` and `.eds`
files. It can generate c code for use with the canfestival library.
reading and writing OD files in `.json`/`.jsonc` format, in legacy XML `.od`
and `.eds` files. It can generate c code for use with the canfestival library.


## Install
Expand Down Expand Up @@ -76,22 +76,19 @@ descriptions, help with values and validate the file.
"json.schemas": [
{
"fileMatch": [
"**.json"
"**.jsonc"
],
"url": "./src/objdictgen/schema/od.schema.json"
}
],
"files.associations": {
"*.json": "jsonc"
}
```

## Conversion

The recommended way to convert existing/legacy `.od` files to the new JSON
format is:

$ odg generate <file.od> <file.json> --fix --drop-unused [--nosort]
$ odg generate <file.od> <file.jsonc> --fix --drop-unused [--nosort]

The `--fix` option might be necessary if the OD-file contains internal
inconsistencies. It is safe to run this option as it will not delete any active
Expand All @@ -102,10 +99,10 @@ parameter that might be used in the file.
## Motivation

The biggest improvement with the new tool over the original implementation is
the introduction of a new `.json` based format to store the object dictionary.
The JSON format is well-known and easy to read. The tool supports jsonc,
the introduction of a new `.jsonc` based format to store the object dictionary.
The JSON format is well-known and easy to read. The tool use jsonc,
allowing comments in the json file. `odg` will process the file in a repeatable
manner, making it possible support diffing of the `.json` file output. `odg`
manner, making it possible support diffing of the `.jsonc` file output. `odg`
remains 100% compatible with the legacy `.od` format on both input and output.

The original objdictedit and objdictgen tool were written in legacy python 2 and
Expand Down
7 changes: 4 additions & 3 deletions src/objdictgen/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ def main(debugopts: DebugOpts, args: Sequence[str]|None = None):
subp.add_argument('-x', '--exclude', action="append", help="OD Index to exclude.")
subp.add_argument('-f', '--fix', action="store_true",
help="Fix any inconsistency errors in OD before generate output")
subp.add_argument('-t', '--type', choices=['od', 'eds', 'json', 'c'],
subp.add_argument('-t', '--type', choices=['od', 'eds', 'json', 'jsonc', 'c'],
help="Select output file type")
subp.add_argument('--drop-unused', action="store_true", help="Remove unused parameters")
subp.add_argument('--internal', action="store_true",
Expand Down Expand Up @@ -351,8 +351,9 @@ def main(debugopts: DebugOpts, args: Sequence[str]|None = None):

# Write the data
od.DumpFile(opts.out,
filetype=opts.type, sort=not opts.no_sort,
internal=opts.internal, validate=not opts.novalidate
filetype=opts.type,
# These additional options are only used for JSON output
sort=not opts.no_sort, internal=opts.internal, validate=not opts.novalidate
)


Expand Down
24 changes: 21 additions & 3 deletions src/objdictgen/jsonod.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,8 @@ def compare_profile(profilename: TPath, params: ODMapping, menu: TProfileMenu|No
return False, False


def generate_jsonc(node: "Node", compact=False, sort=False, internal=False, validate=True) -> str:
def generate_jsonc(node: "Node", compact=False, sort=False, internal=False,
validate=True, jsonc=True) -> str:
""" Export a JSONC string representation of the node """

# Get the dict representation
Expand All @@ -374,20 +375,37 @@ def generate_jsonc(node: "Node", compact=False, sort=False, internal=False, vali
text = json.dumps(jd, separators=(',', ': '), indent=2)

# Convert the special __ fields to jsonc comments
# Syntax: "__<field>: <value>"
text = re.sub(
r'^(\s*)"__(\w+)": "(.*)",?$',
r'\1// "\2": "\3"',
# In regular json files, __* fields are omitted from output
# In jsonc files, the __* entry is converted to a comment:
# "// <field>: <value>"
r'\1// "\2": "\3"' if jsonc else '',
text,
flags=re.MULTILINE,
)

if jsonc:
# In jsonc the field is converted to "<field>, // <comment>"
repl = lambda m: m[1].replace('\\"', '"') + m[3] + m[2]
else:
# In json the field is converted to "<field>,"
repl = lambda m: m[1].replace('\\"', '"') + m[3]

# Convert the special @@ fields to jsonc comments
# Syntax: "@@<field>, // <comment>@@"
text = re.sub(
r'"@@(.*?)(\s*//.*?)?@@"(.*)$',
lambda m: m[1].replace('\\"', '"') + m[3] + m[2],
repl,
text,
flags=re.MULTILINE,
)

# In case the json contains empty lines, remove them
if not jsonc:
text = "\n".join(line for line in text.splitlines() if line.strip())

return text


Expand Down
14 changes: 8 additions & 6 deletions src/objdictgen/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,14 +196,14 @@ def LoadJson(contents: str) -> "Node":
""" Import a new Node from a JSON string """
return jsonod.generate_node(contents)

def DumpFile(self, filepath: TPath, filetype: str|None = "json", **kwargs):
def DumpFile(self, filepath: TPath, filetype: str|None = "jsonc", **kwargs):
""" Save node into file """

# Attempt to determine the filetype from the filepath
if not filetype:
filetype = Path(filepath).suffix[1:]
if not filetype:
filetype = "json"
filetype = "jsonc"

if filetype == 'od':
log.debug("Writing XML OD '%s'", filepath)
Expand All @@ -219,9 +219,11 @@ def DumpFile(self, filepath: TPath, filetype: str|None = "json", **kwargs):
f.write(content)
return

if filetype == 'json':
if filetype in ('json', 'jsonc'):
log.debug("Writing JSON OD '%s'", filepath)
jdata = self.DumpJson(**kwargs)
kw = kwargs.copy()
kw['jsonc'] = filetype == 'jsonc'
jdata = self.DumpJson(**kw)
with open(filepath, "w", encoding="utf-8") as f:
f.write(jdata)
return
Expand All @@ -234,10 +236,10 @@ def DumpFile(self, filepath: TPath, filetype: str|None = "json", **kwargs):

raise ValueError("Unknown file suffix, unable to write file")

def DumpJson(self, compact=False, sort=False, internal=False, validate=True) -> str:
def DumpJson(self, compact=False, sort=False, internal=False, validate=True, jsonc=True) -> str:
""" Dump the node into a JSON string """
return jsonod.generate_jsonc(
self, compact=compact, sort=sort, internal=internal, validate=validate
self, compact=compact, sort=sort, internal=internal, validate=validate, jsonc=jsonc
)

def asdict(self) -> dict[str, Any]:
Expand Down
4 changes: 2 additions & 2 deletions src/objdictgen/ui/objdictedit.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ def OnOpenMenu(self, event): # pylint: disable=unused-argument

with wx.FileDialog(
self, "Choose a file", directory, "",
"OD files (*.json;*.od;*.eds)|*.json;*.od;*.eds|All files|*.*",
wildcard="OD JSON file (*.jsonc;*.json)|*.jsonc;*.json|Legacy OD file (*.od)|*.od|EDS file (*.eds)|*.eds|All files|*.*",
style=wx.FD_OPEN | wx.FD_CHANGE_DIR,
) as dialog:
if dialog.ShowModal() != wx.ID_OK:
Expand Down Expand Up @@ -475,7 +475,7 @@ def SaveAs(self):

with wx.FileDialog(
self, "Choose a file", directory, filename,
wildcard="OD JSON file (*.json)|*.json;|OD file (*.od)|*.od;|EDS file (*.eds)|*.eds",
wildcard="OD JSON file (*.jsonc;*.json)|*.jsonc;*.json|Legacy OD file (*.od)|*.od|EDS file (*.eds)|*.eds",
style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT | wx.FD_CHANGE_DIR,
) as dialog:
if dialog.ShowModal() != wx.ID_OK:
Expand Down
7 changes: 4 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ def pytest_generate_tests(metafunc):
# Add "_suffix" fixture
if "_suffix" in metafunc.fixturenames:
metafunc.parametrize(
"_suffix", ['od', 'json', 'eds'], indirect=False, scope="session"
"_suffix", ['od', 'jsonc', 'json', 'eds'], indirect=False, scope="session"
)

# Make a list of all .od files in tests/od
Expand All @@ -211,6 +211,7 @@ def pytest_generate_tests(metafunc):
jsonfiles = []
for d in oddirs:
jsonfiles += ODPath.nfactory(d.glob('*.json'))
jsonfiles += ODPath.nfactory(d.glob('*.jsonc'))

edsfiles = []
for d in oddirs:
Expand All @@ -228,15 +229,15 @@ def odids(odlist):
)

# Add "odjson" fixture
# Fixture for each of the .od and .json files in the test directory
# Fixture for each of the .od and .json[c] files in the test directory
if "odjson" in metafunc.fixturenames:
data = sorted(odfiles + jsonfiles)
metafunc.parametrize(
"odjson", data, ids=odids(data), indirect=False, scope="session"
)

# Add "odjsoneds" fixture
# Fixture for each of the .od, .json, and .eds files in the test directory
# Fixture for each of the .od, .json[c], and .eds files in the test directory
if "odjsoneds" in metafunc.fixturenames:
data = sorted(odfiles + jsonfiles + edsfiles)
metafunc.parametrize(
Expand Down
12 changes: 3 additions & 9 deletions tests/od/jsonod-comments.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@
"default_string_size": 24,
"dictionary": [
{
"index": "0x1003", // 4099
"index": "0x1003",
"name": "Pre-defined Error Field",
"struct": "array",
"group": "built-in",
"mandatory": false,
"profile_callback": true,
"each": {
"name": "Standard Error Field",
"type": "UNSIGNED32", // 7
"type": "UNSIGNED32",
"access": "ro",
"pdo": false,
"nbmin": 1,
Expand All @@ -29,23 +29,17 @@
"sub": [
{
"name": "Number of Errors",
"type": "UNSIGNED8", // 5
"type": "UNSIGNED8",
"access": "rw",
"pdo": false
},
{
// "name": "Standard Error Field"
// "type": "UNSIGNED32" // 7
"value": 0
},
{
// "name": "Standard Error Field"
// "type": "UNSIGNED32" // 7
"value": 0
},
{
// "name": "Standard Error Field"
// "type": "UNSIGNED32" // 7
"value": 0
}
]
Expand Down
54 changes: 54 additions & 0 deletions tests/od/jsonod-comments.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"$id": "od data",
"$version": "1",
"$description": "Canfestival object dictionary data",
"$tool": "odg 3.5",
"$date": "2024-08-13T11:36:24.609168",
"name": "JSON comments",
"description": "Test for JSON comments",
"type": "slave",
"id": 0,
"profile": "None",
"default_string_size": 24,
"dictionary": [
{
"index": "0x1003", // 4099
"name": "Pre-defined Error Field",
"struct": "array",
"group": "built-in",
"mandatory": false,
"profile_callback": true,
"each": {
"name": "Standard Error Field",
"type": "UNSIGNED32", // 7
"access": "ro",
"pdo": false,
"nbmin": 1,
"nbmax": 254
},
"sub": [
{
"name": "Number of Errors",
"type": "UNSIGNED8", // 5
"access": "rw",
"pdo": false
},
{
// "name": "Standard Error Field"
// "type": "UNSIGNED32" // 7
"value": 0
},
{
// "name": "Standard Error Field"
// "type": "UNSIGNED32" // 7
"value": 0
},
{
// "name": "Standard Error Field"
// "type": "UNSIGNED32" // 7
"value": 0
}
]
}
]
}
27 changes: 20 additions & 7 deletions tests/test_jsonod.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,15 +135,28 @@ def test_jsonod_timezone():
def test_jsonod_comments(odpath):
""" Test that the json file exports comments correctly. """

fname = odpath / "jsonod-comments.json"
m1 = Node.LoadFile(fname)
with open(fname, "r") as f:
od = f.read()
fname_jsonc = odpath / "jsonod-comments.jsonc"
fname_json = odpath / "jsonod-comments.json"

out = generate_jsonc(m1, compact=False, sort=False, internal=False, validate=True)
m1 = Node.LoadFile(fname_jsonc)

with open(fname_jsonc, "r") as f:
jsonc_data = f.read()
with open(fname_json, "r") as f:
json_data = f.read()

out = generate_jsonc(m1, compact=False, sort=False, internal=False, validate=True, jsonc=True)

# Compare the jsonc data with the generated data
for a, b in zip(jsonc_data.splitlines(), out.splitlines()):
if '"$date"' in a or '"$tool"' in a:
continue
assert a == b

out = generate_jsonc(m1, compact=False, sort=False, internal=False, validate=True, jsonc=False)

for a, b in zip(od.splitlines(), out.splitlines()):
print(a)
# Compare the json data with the generated data
for a, b in zip(json_data.splitlines(), out.splitlines()):
if '"$date"' in a or '"$tool"' in a:
continue
assert a == b

0 comments on commit 0b8cc2d

Please sign in to comment.