From: Victor Toso <victortoso@redhat.com>
To: qemu-devel@nongnu.org
Cc: "Markus Armbruster" <armbru@redhat.com>,
"John Snow" <jsnow@redhat.com>,
"Daniel P . Berrangé" <berrange@redhat.com>,
"Andrea Bolognani" <abologna@redhat.com>
Subject: [PATCH v4 03/11] qapi: golang: Generate alternate types
Date: Fri, 14 Feb 2025 21:29:36 +0100 [thread overview]
Message-ID: <20250214202944.69897-4-victortoso@redhat.com> (raw)
In-Reply-To: <20250214202944.69897-1-victortoso@redhat.com>
This patch handles QAPI alternate types and generates data structures
in Go that handles it.
Alternate types are similar to Union but without a discriminator that
can be used to identify the underlying value on the wire.
1. Over the wire, we need to infer underlying value by its type
2. Pointer to types are mapped as optional. Absent value can be a
valid value.
3. We use Go's standard 'encoding/json' library with its Marshal
and Unmarshal interfaces.
4. As an exceptional but valid case, there are types that accept
JSON NULL as value. Due to limitations with Go's standard library
(point 3) combined with Absent being a possibility (point 2), we
translante NULL values to a boolean field called 'IsNull'. See the
second example and docs/devel/qapi-golang-code-gen.rst under
Alternate section.
* First example:
qapi:
| ##
| # @BlockdevRef:
| #
| # Reference to a block device.
| #
| # @definition: defines a new block device inline
| #
| # @reference: references the ID of an existing block device
| #
| # Since: 2.9
| ##
| { 'alternate': 'BlockdevRef',
| 'data': { 'definition': 'BlockdevOptions',
| 'reference': 'str' } }
go:
| // Reference to a block device.
| //
| // Since: 2.9
| type BlockdevRef struct {
| // defines a new block device inline
| Definition *BlockdevOptions
| // references the ID of an existing block device
| Reference *string
| }
|
| func (s BlockdevRef) MarshalJSON() ([]byte, error) {
| ...
| }
|
| func (s *BlockdevRef) UnmarshalJSON(data []byte) error {
| ...
| }
usage:
| input := `{"driver":"qcow2","data-file":"/some/place/my-image"}`
| k := BlockdevRef{}
| err := json.Unmarshal([]byte(input), &k)
| if err != nil {
| panic(err)
| }
| // *k.Definition.Qcow2.DataFile.Reference == "/some/place/my-image"
* Second example:
qapi:
| { 'alternate': 'StrOrNull',
| 'data': { 's': 'str',
| 'n': 'null' } }
| // This is a string value or the explicit lack of a string (null
| // pointer in C). Intended for cases when 'optional absent' already
| // has a different meaning.
| //
| // Since: 2.10
| type StrOrNull struct {
| // the string value
| S *string
| // no string value
| IsNull bool
| }
|
| // Helper function to get its underlying Go value or absent of value
| func (s *StrOrNull) ToAnyOrAbsent() (any, bool) {
| ...
| }
|
| func (s StrOrNull) MarshalJSON() ([]byte, error) {
| ...
| }
|
| func (s *StrOrNull) UnmarshalJSON(data []byte) error {
| ...
| }
Signed-off-by: Victor Toso <victortoso@redhat.com>
---
scripts/qapi/golang/golang.py | 306 +++++++++++++++++++++++++++++++++-
scripts/qapi/golang/utils.go | 26 +++
2 files changed, 329 insertions(+), 3 deletions(-)
create mode 100644 scripts/qapi/golang/utils.go
diff --git a/scripts/qapi/golang/golang.py b/scripts/qapi/golang/golang.py
index f074ec1f6f..aa1a18a501 100644
--- a/scripts/qapi/golang/golang.py
+++ b/scripts/qapi/golang/golang.py
@@ -14,10 +14,11 @@
from __future__ import annotations
import os, shutil, textwrap
-from typing import List, Optional
+from typing import List, Optional, Tuple
from ..schema import (
QAPISchema,
+ QAPISchemaAlternateType,
QAPISchemaBranches,
QAPISchemaEnumMember,
QAPISchemaFeature,
@@ -30,6 +31,8 @@
)
from ..source import QAPISourceInfo
+FOUR_SPACES = " "
+
TEMPLATE_GENERATED_HEADER = """
/*
* Copyright 2025 Red Hat, Inc.
@@ -56,6 +59,57 @@
)
"""
+TEMPLATE_ALTERNATE_CHECK_INVALID_JSON_NULL = """
+ // Check for json-null first
+ if string(data) == "null" {{
+ return errors.New(`null not supported for {name}`)
+ }}"""
+
+TEMPLATE_ALTERNATE_NULLABLE_CHECK = """
+ }} else if s.{var_name} != nil {{
+ return *s.{var_name}, false"""
+
+TEMPLATE_ALTERNATE_MARSHAL_CHECK = """
+ if s.{var_name} != nil {{
+ return json.Marshal(s.{var_name})
+ }} else """
+
+TEMPLATE_ALTERNATE_UNMARSHAL_CHECK = """
+ // Check for {var_type}
+ {{
+ s.{var_name} = new({var_type})
+ if err := strictDecode(s.{var_name}, data); err == nil {{
+ return nil
+ }}
+ s.{var_name} = nil
+ }}
+
+"""
+
+TEMPLATE_ALTERNATE_NULLABLE_MARSHAL_CHECK = """
+ if s.IsNull {
+ return []byte("null"), nil
+ } else """
+
+TEMPLATE_ALTERNATE_NULLABLE_UNMARSHAL_CHECK = """
+ // Check for json-null first
+ if string(data) == "null" {
+ s.IsNull = true
+ return nil
+ }"""
+
+TEMPLATE_ALTERNATE_METHODS = """
+func (s {name}) MarshalJSON() ([]byte, error) {{
+{marshal_check_fields}
+ return {marshal_return_default}
+}}
+
+func (s *{name}) UnmarshalJSON(data []byte) error {{
+{unmarshal_check_fields}
+ return fmt.Errorf("Can't convert to {name}: %s", string(data))
+}}
+"""
+
# Takes the documentation object of a specific type and returns
# that type's documentation and its member's docs.
@@ -96,10 +150,88 @@ def gen_golang(schema: QAPISchema, output_dir: str, prefix: str) -> None:
vis.write(output_dir)
+def qapi_to_field_name(name: str) -> str:
+ return name.title().replace("_", "").replace("-", "")
+
+
def qapi_to_field_name_enum(name: str) -> str:
return name.title().replace("-", "")
+def qapi_schema_type_to_go_type(qapitype: str) -> str:
+ schema_types_to_go = {
+ "str": "string",
+ "null": "nil",
+ "bool": "bool",
+ "number": "float64",
+ "size": "uint64",
+ "int": "int64",
+ "int8": "int8",
+ "int16": "int16",
+ "int32": "int32",
+ "int64": "int64",
+ "uint8": "uint8",
+ "uint16": "uint16",
+ "uint32": "uint32",
+ "uint64": "uint64",
+ "any": "any",
+ "QType": "QType",
+ }
+
+ prefix = ""
+ if qapitype.endswith("List"):
+ prefix = "[]"
+ qapitype = qapitype[:-4]
+
+ qapitype = schema_types_to_go.get(qapitype, qapitype)
+ return prefix + qapitype
+
+
+# Helper for Alternate generation
+def qapi_field_to_alternate_go_field(
+ member_name: str, type_name: str
+) -> Tuple[str, str, str]:
+ # Nothing to generate on null types. We update some
+ # variables to handle json-null on marshalling methods.
+ if type_name == "null":
+ return "IsNull", "bool", ""
+
+ # On Alternates, fields are optional represented in Go as pointer
+ return (
+ qapi_to_field_name(member_name),
+ qapi_schema_type_to_go_type(type_name),
+ "*",
+ )
+
+
+def fetch_indent_blocks_over_args(
+ args: List[dict[str:str]],
+) -> Tuple[int, int]:
+ maxname, maxtype = 0, 0
+ blocks: tuple(int, int) = []
+ for arg in args:
+ if "comment" in arg or "doc" in arg:
+ blocks.append((maxname, maxtype))
+ maxname, maxtype = 0, 0
+
+ if "comment" in arg:
+ # They are single blocks
+ continue
+
+ if "type" not in arg:
+ # Embed type are on top of the struct and the following
+ # fields do not consider it for formatting
+ blocks.append((maxname, maxtype))
+ maxname, maxtype = 0, 0
+ continue
+
+ maxname = max(maxname, len(arg.get("name", "")))
+ maxtype = max(maxtype, len(arg.get("type", "")))
+
+ blocks.append((maxname, maxtype))
+ return blocks
+
+
def fetch_indent_blocks_over_enum_with_docs(
name: str, members: List[QAPISchemaEnumMember], docfields: Dict[str, str]
) -> Tuple[int]:
@@ -122,6 +254,137 @@ def fetch_indent_blocks_over_enum_with_docs(
return blocks
+# Helper function for boxed or self contained structures.
+def generate_struct_type(
+ type_name,
+ type_doc: str = "",
+ args: List[dict[str:str]] = None,
+ indent: int = 0,
+) -> str:
+ base_indent = FOUR_SPACES * indent
+
+ with_type = ""
+ if type_name != "":
+ with_type = f"\n{base_indent}type {type_name}"
+
+ if type_doc != "":
+ # Append line jump only if type_doc exists
+ type_doc = f"\n{type_doc}"
+
+ if args is None:
+ # No args, early return
+ return f"""{type_doc}{with_type} struct{{}}"""
+
+ # The logic below is to generate fields of the struct.
+ # We have to be mindful of the different indentation possibilities between
+ # $var_name $var_type $var_tag that are vertically indented with gofmt.
+ #
+ # So, we first have to iterate over all args and find all indent blocks
+ # by calculating the spaces between (1) member and type and between (2)
+ # the type and tag. (1) and (2) is the tuple present in List returned
+ # by the helper function fetch_indent_blocks_over_args.
+ inner_indent = base_indent + FOUR_SPACES
+ doc_indent = inner_indent + "// "
+ fmt = textwrap.TextWrapper(
+ width=70, initial_indent=doc_indent, subsequent_indent=doc_indent
+ )
+
+ indent_block = iter(fetch_indent_blocks_over_args(args))
+ maxname, maxtype = next(indent_block)
+ members = " {\n"
+ for index, arg in enumerate(args):
+ if "comment" in arg:
+ maxname, maxtype = next(indent_block)
+ members += f""" // {arg["comment"]}\n"""
+ # comments are single blocks, so we can skip to next arg
+ continue
+
+ name2type = ""
+ if "doc" in arg:
+ maxname, maxtype = next(indent_block)
+ members += fmt.fill(arg["doc"])
+ members += "\n"
+
+ name = arg["name"]
+ if "type" in arg:
+ namelen = len(name)
+ name2type = " " * max(1, (maxname - namelen + 1))
+
+ type2tag = ""
+ if "tag" in arg:
+ typelen = len(arg["type"])
+ type2tag = " " * max(1, (maxtype - typelen + 1))
+
+ gotype = arg.get("type", "")
+ tag = arg.get("tag", "")
+ members += (
+ f"""{inner_indent}{name}{name2type}{gotype}{type2tag}{tag}\n"""
+ )
+
+ members += f"{base_indent}}}\n"
+ return f"""{type_doc}{with_type} struct{members}"""
+
+
+def generate_template_alternate(
+ self: QAPISchemaGenGolangVisitor,
+ name: str,
+ variants: Optional[QAPISchemaVariants],
+) -> str:
+ args: List[dict[str:str]] = []
+ nullable = name in self.accept_null_types
+ if nullable:
+ # Examples in QEMU QAPI schema: StrOrNull and BlockdevRefOrNull
+ marshal_return_default = """[]byte("{}"), nil"""
+ marshal_check_fields = TEMPLATE_ALTERNATE_NULLABLE_MARSHAL_CHECK[1:]
+ unmarshal_check_fields = TEMPLATE_ALTERNATE_NULLABLE_UNMARSHAL_CHECK
+ else:
+ marshal_return_default = f'nil, errors.New("{name} has empty fields")'
+ marshal_check_fields = ""
+ unmarshal_check_fields = (
+ TEMPLATE_ALTERNATE_CHECK_INVALID_JSON_NULL.format(name=name)
+ )
+
+ doc = self.docmap.get(name, None)
+ content, docfields = qapi_to_golang_struct_docs(doc)
+ if variants:
+ for var in variants.variants:
+ var_name, var_type, isptr = qapi_field_to_alternate_go_field(
+ var.name, var.type.name
+ )
+ args.append(
+ {
+ "name": f"{var_name}",
+ "type": f"{isptr}{var_type}",
+ "doc": docfields.get(var.name, ""),
+ }
+ )
+ # Null is special, handled first
+ if var.type.name == "null":
+ assert nullable
+ continue
+
+ skip_indent = 1 + len(FOUR_SPACES)
+ if marshal_check_fields == "":
+ skip_indent = 1
+ marshal_check_fields += TEMPLATE_ALTERNATE_MARSHAL_CHECK[
+ skip_indent:
+ ].format(var_name=var_name)
+ unmarshal_check_fields += TEMPLATE_ALTERNATE_UNMARSHAL_CHECK[
+ :-1
+ ].format(var_name=var_name, var_type=var_type)
+
+ content += string_to_code(generate_struct_type(name, args=args))
+ content += string_to_code(
+ TEMPLATE_ALTERNATE_METHODS.format(
+ name=name,
+ marshal_check_fields=marshal_check_fields[:-6],
+ marshal_return_default=marshal_return_default,
+ unmarshal_check_fields=unmarshal_check_fields[1:],
+ )
+ )
+ return "\n" + content
+
+
def generate_content_from_dict(data: dict[str, str]) -> str:
content = ""
@@ -131,6 +394,25 @@ def generate_content_from_dict(data: dict[str, str]) -> str:
return content.replace("\n\n\n", "\n\n")
+def string_to_code(text: str) -> str:
+ DOUBLE_BACKTICK = "``"
+ result = ""
+ for line in text.splitlines():
+ # replace left four spaces with tabs
+ limit = len(line) - len(line.lstrip())
+ result += line[:limit].replace(FOUR_SPACES, "\t")
+
+ # work with the rest of the line
+ if line[limit : limit + 2] == "//":
+ # gofmt tool does not like comments with backticks.
+ result += line[limit:].replace(DOUBLE_BACKTICK, '"')
+ else:
+ result += line[limit:]
+ result += "\n"
+
+ return result
+
+
def generate_template_imports(words: List[str]) -> str:
if len(words) == 0:
return ""
@@ -147,9 +429,10 @@ class QAPISchemaGenGolangVisitor(QAPISchemaVisitor):
# pylint: disable=too-many-arguments
def __init__(self, _: str):
super().__init__()
- gofiles = ("protocol.go",)
+ gofiles = ("protocol.go", "utils.go")
# Map each qapi type to the necessary Go imports
types = {
+ "alternate": ["encoding/json", "errors", "fmt"],
"enum": [],
}
@@ -157,6 +440,8 @@ def __init__(self, _: str):
self.golang_package_name = "qapi"
self.duplicate = list(gofiles)
self.enums: dict[str, str] = {}
+ self.alternates: dict[str, str] = {}
+ self.accept_null_types = []
self.docmap = {}
self.types = dict.fromkeys(types, "")
@@ -165,6 +450,17 @@ def __init__(self, _: str):
def visit_begin(self, schema: QAPISchema) -> None:
self.schema = schema
+ # We need to be aware of any types that accept JSON NULL
+ for name, entity in self.schema._entity_dict.items():
+ if not isinstance(entity, QAPISchemaAlternateType):
+ # Assume that only Alternate types accept JSON NULL
+ continue
+
+ for var in entity.alternatives.variants:
+ if var.type.name == "null":
+ self.accept_null_types.append(name)
+ break
+
# iterate once in schema.docs to map doc objects to its name
for doc in schema.docs:
if doc.symbol is None:
@@ -180,6 +476,7 @@ def visit_begin(self, schema: QAPISchema) -> None:
def visit_end(self) -> None:
del self.schema
self.types["enum"] += generate_content_from_dict(self.enums)
+ self.types["alternate"] += generate_content_from_dict(self.alternates)
def visit_object_type(
self,
@@ -201,7 +498,10 @@ def visit_alternate_type(
features: List[QAPISchemaFeature],
variants: QAPISchemaVariants,
) -> None:
- pass
+ assert name not in self.alternates
+ self.alternates[name] = generate_template_alternate(
+ self, name, variants
+ )
def visit_enum_type(
self,
diff --git a/scripts/qapi/golang/utils.go b/scripts/qapi/golang/utils.go
new file mode 100644
index 0000000000..f00c0a5d83
--- /dev/null
+++ b/scripts/qapi/golang/utils.go
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2025 Red Hat, Inc.
+ * SPDX-License-Identifier: MIT-0
+ *
+ * Authors:
+ * Victor Toso <victortoso@redhat.com>
+ */
+package qapi
+
+import (
+ "encoding/json"
+ "strings"
+)
+
+// Creates a decoder that errors on unknown Fields
+// Returns nil if successfully decoded @from payload to @into type
+// Returns error if failed to decode @from payload to @into type
+func strictDecode(into interface{}, from []byte) error {
+ dec := json.NewDecoder(strings.NewReader(string(from)))
+ dec.DisallowUnknownFields()
+
+ if err := dec.Decode(into); err != nil {
+ return err
+ }
+ return nil
+}
--
2.48.1
next prev parent reply other threads:[~2025-02-14 20:30 UTC|newest]
Thread overview: 18+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-02-14 20:29 [PATCH v4 00/11] Victor Toso
2025-02-14 20:29 ` [PATCH v4 01/11] qapi: golang: first level unmarshalling type Victor Toso
2025-02-14 20:29 ` [PATCH v4 02/11] qapi: golang: Generate enum type Victor Toso
2025-02-14 20:29 ` Victor Toso [this message]
2025-02-14 20:29 ` [PATCH v4 04/11] qapi: golang: Generate struct types Victor Toso
2025-02-14 20:29 ` [PATCH v4 05/11] qapi: golang: structs: Address nullable members Victor Toso
2025-02-14 20:29 ` [PATCH v4 06/11] qapi: golang: Generate union type Victor Toso
2025-02-14 20:29 ` [PATCH v4 07/11] qapi: golang: Generate event type Victor Toso
2025-02-14 20:29 ` [PATCH v4 08/11] qapi: golang: Generate Event interface Victor Toso
2025-02-14 20:29 ` [PATCH v4 09/11] qapi: golang: Generate command type Victor Toso
2025-02-14 20:29 ` [PATCH v4 10/11] qapi: golang: Generate Command sync/async interfaces Victor Toso
2025-02-14 20:29 ` [PATCH v4 11/11] docs: add notes on Golang code generator Victor Toso
2025-02-17 13:13 ` [PATCH v4 00/11] Daniel P. Berrangé
2025-02-20 8:06 ` Markus Armbruster
2025-02-17 14:58 ` Daniel P. Berrangé
2025-02-17 16:52 ` Victor Toso
2025-03-07 11:25 ` Victor Toso
2025-03-07 11:33 ` Daniel P. Berrangé
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20250214202944.69897-4-victortoso@redhat.com \
--to=victortoso@redhat.com \
--cc=abologna@redhat.com \
--cc=armbru@redhat.com \
--cc=berrange@redhat.com \
--cc=jsnow@redhat.com \
--cc=qemu-devel@nongnu.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox;
as well as URLs for NNTP newsgroup(s).