[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
Re: [PATCH v1 2/9] qapi: golang: Generate qapi's alternate types in Go
|
From: |
Victor Toso |
|
Subject: |
Re: [PATCH v1 2/9] qapi: golang: Generate qapi's alternate types in Go |
|
Date: |
Wed, 4 Oct 2023 18:46:21 +0200 |
Hi,
On Mon, Oct 02, 2023 at 04:36:11PM -0400, John Snow wrote:
> On Wed, Sep 27, 2023 at 7:25 AM Victor Toso <victortoso@redhat.com> wrote:
> >
> > 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. It is needed
> > to infer it. In Go, most of the types [*] are mapped as optional
> > fields and Marshal and Unmarshal methods will be handling the data
> > checks.
> >
> > Example:
> >
> > qapi:
> > | { 'alternate': 'BlockdevRef',
> > | 'data': { 'definition': 'BlockdevOptions',
> > | 'reference': 'str' } }
> >
> > go:
> > | type BlockdevRef struct {
> > | Definition *BlockdevOptions
> > | Reference *string
> > | }
> >
> > 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"
> >
> > [*] The exception for optional fields as default is to Types that can
> > accept JSON Null as a value like StrOrNull and BlockdevRefOrNull. For
> > this case, we translate Null with a boolean value in a field called
> > IsNull. This will be explained better in the documentation patch of
> > this series but the main rationale is around Marshaling to and from
> > JSON and Go data structures.
> >
> > Example:
> >
> > qapi:
> > | { 'alternate': 'StrOrNull',
> > | 'data': { 's': 'str',
> > | 'n': 'null' } }
> >
> > go:
> > | type StrOrNull struct {
> > | S *string
> > | IsNull bool
> > | }
> >
> > Signed-off-by: Victor Toso <victortoso@redhat.com>
> > ---
> > scripts/qapi/golang.py | 188 ++++++++++++++++++++++++++++++++++++++++-
> > 1 file changed, 185 insertions(+), 3 deletions(-)
> >
> > diff --git a/scripts/qapi/golang.py b/scripts/qapi/golang.py
> > index 87081cdd05..43dbdde14c 100644
> > --- a/scripts/qapi/golang.py
> > +++ b/scripts/qapi/golang.py
> > @@ -16,10 +16,11 @@
> > from __future__ import annotations
> >
> > import os
> > -from typing import List, Optional
> > +from typing import Tuple, List, Optional
> >
> > from .schema import (
> > QAPISchema,
> > + QAPISchemaAlternateType,
> > QAPISchemaType,
> > QAPISchemaVisitor,
> > QAPISchemaEnumMember,
> > @@ -38,6 +39,76 @@
> > )
> > '''
> >
> > +TEMPLATE_HELPER = '''
> > +// 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
> > +}
> > +'''
> > +
> > +TEMPLATE_ALTERNATE = '''
> > +// Only implemented on Alternate types that can take JSON NULL as value.
> > +//
> > +// This is a helper for the marshalling code. It should return true only
> > when
> > +// the Alternate is empty (no members are set), otherwise it returns false
> > and
> > +// the member set to be Marshalled.
> > +type AbsentAlternate interface {
> > + ToAnyOrAbsent() (any, bool)
> > +}
> > +'''
> > +
> > +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 = '''
> > +func (s *{name}) ToAnyOrAbsent() (any, bool) {{
> > + if s != nil {{
> > + if s.IsNull {{
> > + return nil, false
> > +{absent_check_fields}
> > + }}
> > + }}
> > +
> > + return nil, true
> > +}}
> > +'''
> > +
> > +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))
> > +}}
> > +'''
> >
> > def gen_golang(schema: QAPISchema,
> > output_dir: str,
> > @@ -46,27 +117,135 @@ def gen_golang(schema: QAPISchema,
> > schema.visit(vis)
> > 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
> > +
> > +def qapi_field_to_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", ""
> > +
> > + # This function is called on Alternate, so fields should be ptrs
> > + return qapi_to_field_name(member_name),
> > qapi_schema_type_to_go_type(type_name), "*"
> > +
> > +# Helper function for boxed or self contained structures.
> > +def generate_struct_type(type_name, args="") -> str:
> > + args = args if len(args) == 0 else f"\n{args}\n"
> > + with_type = f"\ntype {type_name}" if len(type_name) > 0 else ""
> > + return f'''{with_type} struct {{{args}}}
> > +'''
> > +
> > +def generate_template_alternate(self: QAPISchemaGenGolangVisitor,
> > + name: str,
> > + variants: Optional[QAPISchemaVariants]) ->
> > str:
> > + absent_check_fields = ""
> > + variant_fields = ""
> > + # to avoid having to check accept_null_types
> > + nullable = False
> > + if name in self.accept_null_types:
> > + # In QEMU QAPI schema, only StrOrNull and BlockdevRefOrNull.
> > + nullable = True
> > + marshal_return_default = '''[]byte("{}"), nil'''
> > + marshal_check_fields = '''
> > + if s.IsNull {
> > + return []byte("null"), nil
> > + } else '''
> > + unmarshal_check_fields = '''
> > + // Check for json-null first
> > + if string(data) == "null" {
> > + s.IsNull = true
> > + return nil
> > + }
> > + '''
> > + else:
> > + marshal_return_default = f'nil, errors.New("{name} has empty
> > fields")'
> > + marshal_check_fields = ""
> > + unmarshal_check_fields = f'''
> > + // Check for json-null first
> > + if string(data) == "null" {{
> > + return errors.New(`null not supported for {name}`)
> > + }}
> > + '''
> > +
> > + for var in variants.variants:
>
> qapi/golang.py:213: error: Item "None" of "QAPISchemaVariants | None"
> has no attribute "variants" [union-attr]
>
> if variants is None ( variants: Optional[QAPISchemaVariants]) we
> can't iterate over it without checking to see if it's present first or
> not. It may make more sense to change this field to always be an
> Iterable and have it default to an empty iterable, but as it is
> currently typed we need to check if it's None first.
Sure,
> > + var_name, var_type, isptr = qapi_field_to_go_field(var.name,
> > var.type.name)
> > + variant_fields += f"\t{var_name} {isptr}{var_type}\n"
> > +
> > + # Null is special, handled first
> > + if var.type.name == "null":
> > + assert nullable
> > + continue
> > +
> > + if nullable:
> > + absent_check_fields +=
> > TEMPLATE_ALTERNATE_NULLABLE_CHECK.format(var_name=var_name)[1:]
> > + marshal_check_fields +=
> > TEMPLATE_ALTERNATE_MARSHAL_CHECK.format(var_name=var_name)
> > + unmarshal_check_fields +=
> > TEMPLATE_ALTERNATE_UNMARSHAL_CHECK.format(var_name=var_name,
> > +
> > var_type=var_type)[1:]
> > +
> > + content = generate_struct_type(name, variant_fields)
> > + if nullable:
> > + content += TEMPLATE_ALTERNATE_NULLABLE.format(name=name,
> > +
> > absent_check_fields=absent_check_fields)
> > + content += TEMPLATE_ALTERNATE_METHODS.format(name=name,
> > +
> > marshal_check_fields=marshal_check_fields[1:-5],
> > +
> > marshal_return_default=marshal_return_default,
> > +
> > unmarshal_check_fields=unmarshal_check_fields[1:])
> > + return content
> > +
> >
> > class QAPISchemaGenGolangVisitor(QAPISchemaVisitor):
> >
> > def __init__(self, _: str):
> > super().__init__()
> > - types = ["enum"]
> > + types = ["alternate", "enum", "helper"]
> > self.target = {name: "" for name in types}
> > + self.objects_seen = {}
>
> qapi/golang.py:256: error: Need type annotation for "objects_seen"
> (hint: "objects_seen: Dict[<type>, <type>] = ...") [var-annotated]
>
> self.objects_seen: Dict[str, bool] = {}
>
> > self.schema = None
> > self.golang_package_name = "qapi"
> > + self.accept_null_types = []
>
> qapi/golang.py:259: error: Need type annotation for
> "accept_null_types" (hint: "accept_null_types: List[<type>] = ...")
> [var-annotated]
>
> self.accept_null_types: List[str] = []
>
> >
> > def visit_begin(self, schema):
> > self.schema = schema
> >
> > + # We need to be aware of any types that accept JSON NULL
> > + for name, entity in self.schema._entity_dict.items():
>
> We're reaching into the schema's private fields here. I know you
> avoided touching the core which Markus liked, but we should look into
> giving this a proper interface that we can rely on.
>
> OR, if we all agree that this is fine, and all of this code is
> going to *continue living in the same repository for the
> foreseeable future*, you can just silence this warning.
>
> jsnow@scv ~/s/q/scripts (review)> pylint qapi --rcfile=qapi/pylintrc
> ************* Module qapi.golang
> qapi/golang.py:265:28: W0212: Access to a protected member
> _entity_dict of a client class (protected-access)
>
>
> for name, entity in self.schema._entity_dict.items(): # pylint:
> disable=protected-access
Right. Here I knew it was somewhat bad. It is up to you/Markus. I
can introduce a proper interface in the schema as a preparatory
patch to this one, or we mark this as a problem for the future,
for sure there is no intention to detach this from this repo,
specifically scripts/qapi.
It depends on what you think is best.
> > + if not isinstance(entity, QAPISchemaAlternateType):
> > + # Assume that only Alternate types accept JSON NULL
> > + continue
> > +
> > + for var in entity.variants.variants:
> > + if var.type.name == 'null':
> > + self.accept_null_types.append(name)
> > + break
> > +
> > # Every Go file needs to reference its package name
> > for target in self.target:
> > self.target[target] = f"package {self.golang_package_name}\n"
> >
> > + self.target["helper"] += TEMPLATE_HELPER
> > + self.target["alternate"] += TEMPLATE_ALTERNATE
> > +
> > def visit_end(self):
> > self.schema = None
> >
> > @@ -88,7 +267,10 @@ def visit_alternate_type(self:
> > QAPISchemaGenGolangVisitor,
> > features: List[QAPISchemaFeature],
> > variants: QAPISchemaVariants
> > ) -> None:
> > - pass
> > + assert name not in self.objects_seen
> > + self.objects_seen[name] = True
> > +
> > + self.target["alternate"] += generate_template_alternate(self,
> > name, variants)
> >
> > def visit_enum_type(self: QAPISchemaGenGolangVisitor,
> > name: str,
> > --
> > 2.41.0
> >
>
> flake8 is a little unhappy on this patch. My line numbers here won't
> match up because I've been splicing in my own fixes/edits, but here's
> the gist:
>
> qapi/golang.py:62:1: W191 indentation contains tabs
> qapi/golang.py:62:1: E101 indentation contains mixed spaces and tabs
> qapi/golang.py:111:1: E302 expected 2 blank lines, found 1
> qapi/golang.py:118:1: E302 expected 2 blank lines, found 1
> qapi/golang.py:121:1: E302 expected 2 blank lines, found 1
> qapi/golang.py:124:1: E302 expected 2 blank lines, found 1
> qapi/golang.py:141:1: E302 expected 2 blank lines, found 1
> qapi/golang.py:141:80: E501 line too long (85 > 79 characters)
> qapi/golang.py:148:80: E501 line too long (87 > 79 characters)
> qapi/golang.py:151:1: E302 expected 2 blank lines, found 1
> qapi/golang.py:157:1: E302 expected 2 blank lines, found 1
> qapi/golang.py:190:80: E501 line too long (83 > 79 characters)
> qapi/golang.py:199:80: E501 line too long (98 > 79 characters)
> qapi/golang.py:200:80: E501 line too long (90 > 79 characters)
> qapi/golang.py:201:80: E501 line too long (94 > 79 characters)
> qapi/golang.py:202:80: E501 line too long (98 > 79 characters)
> qapi/golang.py:207:80: E501 line too long (94 > 79 characters)
> qapi/golang.py:209:80: E501 line too long (97 > 79 characters)
> qapi/golang.py:210:80: E501 line too long (95 > 79 characters)
> qapi/golang.py:211:80: E501 line too long (99 > 79 characters)
> qapi/golang.py:236:23: E271 multiple spaces after keyword
> qapi/golang.py:272:80: E501 line too long (85 > 79 characters)
> qapi/golang.py:289:80: E501 line too long (82 > 79 characters)
>
> Mostly just lines being too long and so forth, nothing
> functional. You can fix all of this by running black - I didn't
> use black when I toured qapi last, but it's grown on me since
> and I think it does a reasonable job at applying a braindead
> standard that you don't have to think about.
>
> Try it:
>
> jsnow@scv ~/s/q/scripts (review)> black --line-length 79 qapi/golang.py
> reformatted qapi/golang.py
>
> All done! ✨ 🍰 ✨
> 1 file reformatted.
> jsnow@scv ~/s/q/scripts (review)> flake8 qapi/golang.py
> qapi/golang.py:62:1: W191 indentation contains tabs
> qapi/golang.py:62:1: E101 indentation contains mixed spaces and tabs
>
> The remaining tab stuff happens in your templates/comments and doesn't
> seem to bother anything, but I think you should fix it for the sake of
> the linter tooling in Python if you can.
>
> This is pretty disruptive to the formatting you've chosen so far, but
> I think it's a reasonable standard for new code and removes a lot of
> the thinking. (like gofmt, right?)
>
> Just keep in mind that our line length limitation for QEMU is a bit
> shorter than black's default, so you'll have to tell it the line
> length you want each time. It can be made shorter with "-l 79".
Awesome. I didn't know this tool. I'll apply it to all patches
for v2, fixing all python tooling that you've mention that throws
a warning at me.
Thanks for the patience!
Cheers,
Victor
signature.asc
Description: PGP signature