blob: 7a1ceebfe038984189c6ccde69a7708a52f20697 [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2021 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Hacky script to convert a recipe's dict-style properties to proto properties.
Rationale: protos are the more modern and preferred way of declaring properties.
They're easier to read and understand and are less of a one-off than dict
properties.
Usage: `scripts/props_to_proto.py recipes/foo.py`
Creates a `recipes/foo.proto` file and updates the recipe to import the proto
instead of declaring dict-style properties. The .proto file generation is
best-effort, and generated protos should be inspected for correctness before
committing (`recipes.py test train` will catch most issues).
Limitations:
- Default variable values are lost since protos don't provide a way to declare
default values. `RunSteps()` must be manually updated to set default property
values.
- Does not handle all possible property types. If it encounters a property whose
`kind` it doesn't know how to handle, it will just use the `kind` as the proto
field type, which is generally invalid and will result in an uncompilable
proto until someone manually corrects the field type.
- Does *not* update variable names or function signatures within the recipe –
manual changes will be required to update the signature of RunSteps().
- Does not work for recipe module properties.
- May mess up formatting. Make sure to run `black` before committing.
- Uses structs for all sub-messages instead of custom message types, which are
preferable due to stronger schema guarantees. It's recommended that you
replace structs with explicit sub-messages in most cases.
- No automatic wrapping of proto field comments. Field comments should be
manually wrapped to 80 columns.
"""
import ast
import dataclasses
import datetime
import os
import re
import sys
@dataclasses.dataclass
class ProtoField:
# `Help` string for the property.
docstring: str
# The type that should be used for the proto field.
proto_type: str
# The name of the property.
name: str
# The number that should be used for the proto field corresponding to this
# property.
num: int
# Whether the field is repeated or not.
repeated: bool
def main(filename):
with open(filename) as f:
contents = f.read()
tree = ast.parse(contents)
properties = None
props_assignment = None
for item in tree.body:
if isinstance(item, ast.Assign) and item.targets[0].id == "PROPERTIES":
if not isinstance(item.value, ast.Dict):
print("Recipe already converted to proto properties")
return
props_assignment = item
properties = item.value
break
if not properties:
print("Recipe has no properties")
return
imports = set()
fields = []
for i, (key, val) in enumerate(zip(properties.keys, properties.values)):
prop_name = key.s
kwargs = {kw.arg: kw.value for kw in val.keywords}
kind = ast.unparse(kwargs["kind"])
if "dict" in kind.lower():
imports.add("google/protobuf/struct.proto")
repeated = False
list_match = re.match(r"^List\((\w+)\)$", kind)
if list_match:
repeated = True
kind = list_match.group(1)
proto_type = { # Best effort conversion from Python type to proto type.
"str": "string",
"bool": "bool",
"int": "int32",
"dict": "google.protobuf.Struct",
"Dict()": "google.protobuf.Struct",
}.get(kind, "FIXME(%s)" % kind)
docstring = kwargs.get("help", "").s
if docstring and not docstring.endswith("."):
docstring += "."
default = kwargs.get("default")
if not default:
has_truthy_default = False
elif isinstance(default, ast.Name):
has_truthy_default = default.id != "None"
elif isinstance(default, ast.Constant):
has_truthy_default = bool(default.value)
elif isinstance(default, (ast.Tuple, ast.List)):
has_truthy_default = bool(default.elts)
elif isinstance(default, ast.Dict):
has_truthy_default = bool(default.keys)
else:
has_truthy_default = True
if has_truthy_default:
print(
"WARNING: property %s has default value: %s"
% (prop_name, ast.unparse(default))
)
fields.append(
ProtoField(
docstring=docstring,
proto_type=proto_type,
name=prop_name,
num=i + 1,
repeated=repeated,
)
)
lines = []
for field in fields:
field_lines = []
if field.docstring:
field_lines.append("// %s" % field.docstring)
field_lines.append(
"%s%s %s = %s;"
% (
"repeated " if field.repeated else "",
field.proto_type,
field.name,
field.num,
)
)
lines.append("\n".join(" " + l for l in field_lines))
message_contents = "\n\n".join(lines)
# Remove "recipes" from the beginning to get the name of the recipe as used
# by the recipe engine. E.g. "recipes/contrib/foo.py" -> "contrib.foo".
recipe_path_parts = filename.split(os.path.sep)[1:]
recipe_path_parts[-1] = recipe_path_parts[-1].split(".")[0]
recipe_name = ".".join(recipe_path_parts)
import_block = ""
if imports:
import_lines = ['import "%s";' % imp for imp in sorted(imports)]
import_block = "\n%s\n" % "\n".join(import_lines)
proto_contents = """\
// Copyright %d The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
syntax = "proto3";
package recipes.fuchsia.%s;
%s
message InputProperties {
%s
}
""" % (
datetime.datetime.now().year,
recipe_name,
import_block,
message_contents,
)
with open(filename.replace(".py", ".proto"), "w") as f:
f.write(proto_contents)
update_recipe(tree, filename, recipe_name, contents, props_assignment.lineno)
def update_recipe(tree, filename, recipe_name, contents, props_line):
lines = contents.splitlines()
for i in range(props_line, len(lines)):
if lines[i].endswith("}"):
end_line = i + 1
break
# Not sure why we need to subtract one here...
lines[props_line - 1 : end_line] = ["PROPERTIES = InputProperties"]
import_lineno = 0
for item in tree.body:
if not isinstance(item, (ast.Expr, ast.Import, ast.ImportFrom)):
break
import_lineno = item.lineno
lines.insert(
import_lineno, "from PB.recipes.fuchsia.%s import InputProperties" % recipe_name
)
# Remove imports of types needed for declaring dict-style properties.
lines = [
l
for l in lines
if not re.match(r"from recipe_engine.(recipe_api|config) import", l)
]
with open(filename, "w") as f:
f.write("".join(l + "\n" for l in lines))
if __name__ == "__main__":
main(sys.argv[1])