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 <victort...@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 <victort...@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

Attachment: signature.asc
Description: PGP signature

Reply via email to