From 3c39ce32f93eab38f3e055a6f744780d10dd3aba Mon Sep 17 00:00:00 2001 From: Johann Schramm <43448334+JohannSchramm@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:23:52 +0100 Subject: [PATCH] feat: Generate comments with protobuf Signed-off-by: Johann Schramm <43448334+JohannSchramm@users.noreply.github.com> --- docs/protobuf.md | 26 ++++++++ src/vss_tools/exporters/protobuf.py | 63 ++++++++++++++++--- .../expected_no_comments.proto | 11 ++++ .../expected_with_comments.proto | 27 ++++++++ tests/vspec/test_protobuf_comments/test.vspec | 34 ++++++++++ .../test_protobuf_comments.py | 42 +++++++++++++ 6 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 tests/vspec/test_protobuf_comments/expected_no_comments.proto create mode 100644 tests/vspec/test_protobuf_comments/expected_with_comments.proto create mode 100644 tests/vspec/test_protobuf_comments/test.vspec create mode 100644 tests/vspec/test_protobuf_comments/test_protobuf_comments.py diff --git a/docs/protobuf.md b/docs/protobuf.md index 3fde472e..96a4f643 100644 --- a/docs/protobuf.md +++ b/docs/protobuf.md @@ -15,6 +15,7 @@ This example assumes that you checked out the COVESA VSS repository next to the ```bash --static-uid Expect staticUID attribute in the vspec input and use it as field number. --add-optional Set each field to optional +--include-comments Include descriptions and metadata as comments in the generated proto files ``` ## Field Numbers and Backwards Compatibility @@ -95,3 +96,28 @@ For the incrementally assigned field numbers, we only need a few bits per number In proto3, one can mark a field as `optional` to change the behavior of how to deal with values that are not present during the encoding. By default, the fields are not optional, and you can use the flag `--add-optional` to make all fields optional. See the [Protocol Buffers Language Guide](https://protobuf.dev/programming-guides/proto3/#field-labels) for the implications of using `optional`. + +## Include comments + +By default, the generated proto files do not contain any comments. Use the `--include-comments` flag to include VSS descriptions and metadata as protobuf comments above each message and field. + +The comment block includes the node's description followed by any additional metadata such as allowed values, min/max, default, unit, deprecation, and comment. + +For example, given a vspec entry: + +```yaml +Media.Played.Source: + datatype: string + type: actuator + allowed: ['UNKNOWN', 'SIRIUS_XM', 'AM', 'FM', 'DAB', 'TV', 'CD', 'DVD', 'AUX', 'USB', 'DISK', 'BLUETOOTH', 'INTERNET', 'VOICE', 'BEEP'] + description: Media selected for playback +``` + +The generated proto output with `--include-comments` would be: + +```proto + // Media selected for playback + // + // Allowed: ['UNKNOWN', 'SIRIUS_XM', 'AM', 'FM', 'DAB', 'TV', 'CD', 'DVD', 'AUX', 'USB', 'DISK', 'BLUETOOTH', 'INTERNET', 'VOICE', 'BEEP'] + string Source = 1; +``` diff --git a/src/vss_tools/exporters/protobuf.py b/src/vss_tools/exporters/protobuf.py index 32429ba6..80899b53 100644 --- a/src/vss_tools/exporters/protobuf.py +++ b/src/vss_tools/exporters/protobuf.py @@ -20,6 +20,7 @@ from vss_tools import log from vss_tools.main import get_trees from vss_tools.model import ( + VSSData, VSSDataBranch, VSSDataDatatype, VSSDataStruct, @@ -45,7 +46,7 @@ def init_package_file(path: Path, package_name: str): f.write(f"package {package_name};\n\n") -def traverse_data_type_tree(tree: VSSNode, static_uid: bool, add_optional: bool, out_dir: Path): +def traverse_data_type_tree(tree: VSSNode, static_uid: bool, add_optional: bool, include_comments: bool, out_dir: Path): """ All structs in a branch are written to a single .proto file. The file's base name is same as the branch's name @@ -86,13 +87,21 @@ def traverse_data_type_tree(tree: VSSNode, static_uid: bool, add_optional: bool, write_imports(fd, imports) + if include_comments: + write_comment(fd, node, indent="") fd.write(f"message {struct_path.name} {{" + "\n") - print_messages(node.children, fd, static_uid, add_optional) + print_messages(node.children, fd, static_uid, add_optional, include_comments) fd.write("}\n\n") log.info(f"Wrote {struct_path.name} to {out_file}") -def traverse_signal_tree(tree: VSSNode, fd: TextIOWrapper, static_uid: bool, add_optional: bool): +def traverse_signal_tree( + tree: VSSNode, + fd: TextIOWrapper, + static_uid: bool, + add_optional: bool, + include_comments: bool, +): fd.write('syntax = "proto3";\n\n') imports = [] @@ -106,8 +115,10 @@ def traverse_signal_tree(tree: VSSNode, fd: TextIOWrapper, static_uid: bool, add # write proto messages to file for node in findall(tree, filter_=lambda node: isinstance(node.data, VSSDataBranch)): + if include_comments: + write_comment(fd, node, indent="") fd.write(f"message {node.get_fqn('')} {{" + "\n") - print_messages(node.children, fd, static_uid, add_optional) + print_messages(node.children, fd, static_uid, add_optional, include_comments) fd.write("}\n\n") @@ -119,7 +130,41 @@ def write_imports(fd: TextIOWrapper, imports: list[str]): fd.write("\n") -def print_messages(nodes: tuple[VSSNode], fd: TextIOWrapper, static_uid: bool, add_optional: bool): +def write_comment(fd: TextIOWrapper, node: VSSNode, indent: str = " "): + """Write a protobuf comment block with description and metadata.""" + if not isinstance(node.data, VSSData): + return + lines: list[str] = [] + if node.data.description: + lines.append(node.data.description) + if isinstance(node.data, VSSDataDatatype): + if node.data.allowed is not None: + lines.append(f"Allowed: {node.data.allowed}") + if node.data.min is not None: + lines.append(f"Min: {node.data.min}") + if node.data.max is not None: + lines.append(f"Max: {node.data.max}") + if node.data.default is not None: + lines.append(f"Default: {node.data.default}") + if node.data.unit is not None: + lines.append(f"Unit: {node.data.unit}") + if node.data.deprecation is not None: + lines.append(f"Deprecation: {node.data.deprecation}") + if node.data.comment is not None: + lines.append(f"Comment: {node.data.comment}") + if not lines: + return + fd.write(f"{indent}// {lines[0]}\n") + if len(lines) > 1: + if node.data.description: + fd.write(f"{indent}//\n") + for line in lines[1:]: + fd.write(f"{indent}// {line}\n") + + +def print_messages( + nodes: tuple[VSSNode], fd: TextIOWrapper, static_uid: bool, add_optional: bool, include_comments: bool +): usedKeys: dict[int, str] = {} for i, node in enumerate(nodes, 1): if isinstance(node.data, VSSDataDatatype): @@ -161,6 +206,8 @@ def print_messages(nodes: tuple[VSSNode], fd: TextIOWrapper, static_uid: bool, a usedKeys[fieldNumber] = node.get_fqn() else: fieldNumber = i + if include_comments: + write_comment(fd, node) fd.write(f" {data_type} {node.name} = {fieldNumber};" + "\n") @@ -187,6 +234,7 @@ def print_messages(nodes: tuple[VSSNode], fd: TextIOWrapper, static_uid: bool, a help="Expect staticUID attribute in the vspec input and use it as field number", ) @click.option("--add-optional", is_flag=True, help="Set each field to 'optional'") +@click.option("--include-comments", is_flag=True, help="Include descriptions and metadata as comments") def cli( vspec: Path, output: Path, @@ -201,6 +249,7 @@ def cli( types_out_dir: Path | None, static_uid: bool, add_optional: bool, + include_comments: bool, strict_exceptions: Path | None, ): """ @@ -223,8 +272,8 @@ def cli( if not types_out_dir: types_out_dir = Path.cwd() log.warning(f"No output directory given. Writing to: {types_out_dir.absolute()}") - traverse_data_type_tree(datatype_tree, static_uid, add_optional, types_out_dir) + traverse_data_type_tree(datatype_tree, static_uid, add_optional, include_comments, types_out_dir) with open(output, "w") as f: log.info(f"Writing to: {output}") - traverse_signal_tree(tree, f, static_uid, add_optional) + traverse_signal_tree(tree, f, static_uid, add_optional, include_comments) diff --git a/tests/vspec/test_protobuf_comments/expected_no_comments.proto b/tests/vspec/test_protobuf_comments/expected_no_comments.proto new file mode 100644 index 00000000..b5c4aac4 --- /dev/null +++ b/tests/vspec/test_protobuf_comments/expected_no_comments.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + + +message A { + string StringWithAllowed = 1; + uint32 UInt8WithMinMax = 2; + string DeprecatedString = 3; + float DescOnly = 4; + uint32 NoMetadata = 5; +} + diff --git a/tests/vspec/test_protobuf_comments/expected_with_comments.proto b/tests/vspec/test_protobuf_comments/expected_with_comments.proto new file mode 100644 index 00000000..ec16be2a --- /dev/null +++ b/tests/vspec/test_protobuf_comments/expected_with_comments.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + + +// A is a test branch +message A { + // A string with allowed values + // + // Allowed: ['VALUE_A', 'VALUE_B', 'VALUE_C'] + string StringWithAllowed = 1; + // A uint8 with min and max + // + // Min: 0 + // Max: 100 + // Unit: percent + uint32 UInt8WithMinMax = 2; + // A deprecated node + // + // Deprecation: Use A.StringWithAllowed instead + string DeprecatedString = 3; + // Only a description + // + // Unit: mm + float DescOnly = 4; + // A node with no extra metadata + uint32 NoMetadata = 5; +} + diff --git a/tests/vspec/test_protobuf_comments/test.vspec b/tests/vspec/test_protobuf_comments/test.vspec new file mode 100644 index 00000000..dfb5aed1 --- /dev/null +++ b/tests/vspec/test_protobuf_comments/test.vspec @@ -0,0 +1,34 @@ +A: + type: branch + description: A is a test branch + +A.StringWithAllowed: + datatype: string + type: actuator + allowed: ['VALUE_A', 'VALUE_B', 'VALUE_C'] + description: A string with allowed values + +A.UInt8WithMinMax: + datatype: uint8 + type: sensor + unit: percent + min: 0 + max: 100 + description: A uint8 with min and max + +A.DeprecatedString: + datatype: string + type: sensor + description: A deprecated node + deprecation: Use A.StringWithAllowed instead + +A.DescOnly: + datatype: float + type: sensor + unit: mm + description: Only a description + +A.NoMetadata: + datatype: uint32 + type: sensor + description: A node with no extra metadata diff --git a/tests/vspec/test_protobuf_comments/test_protobuf_comments.py b/tests/vspec/test_protobuf_comments/test_protobuf_comments.py new file mode 100644 index 00000000..07f3ea8d --- /dev/null +++ b/tests/vspec/test_protobuf_comments/test_protobuf_comments.py @@ -0,0 +1,42 @@ +# Copyright (c) 2026 Contributors to COVESA +# +# This program and the accompanying materials are made available under the +# terms of the Mozilla Public License 2.0 which is available at +# https://www.mozilla.org/en-US/MPL/2.0/ +# +# SPDX-License-Identifier: MPL-2.0 + +import filecmp +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +TEST_UNITS = HERE / ".." / "test_units.yaml" +TEST_QUANT = HERE / ".." / "test_quantities.yaml" + + +@pytest.mark.parametrize( + "include_comments, expected_file", + [ + (False, "expected_no_comments.proto"), + (True, "expected_with_comments.proto"), + ], +) +def test_protobuf_comments(include_comments, expected_file, tmp_path): + """ + Test that --include-comments adds description and metadata + as comments to the protobuf output, and that without the flag + no comments are present. + """ + vspec = HERE / "test.vspec" + output = tmp_path / "out.proto" + cmd = f"vspec export protobuf -u {TEST_UNITS} -q {TEST_QUANT} --vspec {vspec} --output {output}" + if include_comments: + cmd += " --include-comments" + subprocess.run(cmd.split(), check=True) + expected = HERE / expected_file + assert filecmp.cmp(output, expected), ( + f"Output differs from expected.\n" f"Got:\n{output.read_text()}\n" f"Expected:\n{expected.read_text()}" + )