Skip to content

Commit

Permalink
feat: add create_grammar_model()
Browse files Browse the repository at this point in the history
  • Loading branch information
tigarmo committed Apr 18, 2024
1 parent 15a7d03 commit ead5a89
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 1 deletion.
2 changes: 2 additions & 0 deletions craft_grammar/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from ._statement import CallStack, Grammar, Statement
from ._to import ToStatement
from ._try import TryStatement
from .create import create_grammar_model

__all__ = [
"errors",
Expand All @@ -38,4 +39,5 @@
"Statement",
"ToStatement",
"TryStatement",
"create_grammar_model",
]
96 changes: 96 additions & 0 deletions craft_grammar/create.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# This file is part of craft-grammar.
#
# Copyright 2024 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License version 3, as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE.
# See the GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Utilities to create grammar models."""
import types
import typing

from pydantic import BaseModel


def create_grammar_model(model_class: typing.Type[BaseModel]) -> str:
"""
Create the declaration for a grammar-aware class compatible with ``model_class``.
:param model_class: A pydantic.BaseModel subclass.
"""
class_decl = f"class Grammar{model_class.__name__}(BaseModel):"

attributes = []
type_hints = typing.get_type_hints(model_class)
for attr_name, attr_type in type_hints.items():
if attr_name == "__slots__":
# This happens in Python 3.12 apparently
continue

grammar_type = _get_grammar_type_for(attr_type)

attr_decl = f"{attr_name}: {grammar_type}"

attr_field = model_class.__fields__[attr_name]
if not attr_field.required:
default = repr(attr_field.default)
# repr(x) uses single quotes for strings; replace them with double
# quotes here to be consistent with the codebase.
default = default.replace("'", '"')
attr_decl += f" = {default}"

attributes.append(attr_decl)

lines = [class_decl]
for attr in attributes:
lines.append(f" {attr}")
lines.append("") # Final newline

return "\n".join(lines)


def _get_grammar_type_for(model_type) -> str:
if model_type is type(None):
return "None"

origin = typing.get_origin(model_type)
args = typing.get_args(model_type)

if origin is None:
return f"Grammar[{model_type.__name__}]"

if origin is typing.Union:
if len(args) == 2 and type(None) in args:
# Optional
other_type = [t for t in args if t is not type(None)][0]
return f"typing.Optional[{other_type.__name__}]"
else:
union_args = []
for arg in typing.get_args(model_type):
if typing.get_origin(arg) is None:
# print int as "int"
union_args.append(arg.__name__)
else:
# print dict[k, v] as "dict[k,v]"
union_args.append(str(arg))
comma_args = ", ".join(union_args)
return f"typing.Union[{comma_args}]"
if origin is types.UnionType:
# A type like "str | None" becomes "Grammar[str] | None"
grammar_args = [_get_grammar_type_for(a) for a in args]
return " | ".join(grammar_args)
elif origin in (list, dict):
return f"Grammar[{str(model_type)}]"
elif origin is typing.Literal:
assert type(typing.get_args(model_type)[0]) is str
return "Grammar[str]"
else:
assert 0, (model_type, origin)
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ packages = find:
zip_safe = False
install_requires =
overrides
pydantic<2.0

[options.package_data]
craft_grammar = py.typed
Expand All @@ -51,7 +52,6 @@ test =
flake8
isort==5.10.1
mypy==0.991
pydantic<2.0
pydocstyle==6.1.1
pylint==2.15.10
pylint-fixme-info
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/test_create.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# This file is part of craft-grammar.
#
# Copyright 2024 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License version 3, as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE.
# See the GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations

from typing import List, Literal, Optional, Union

from pydantic import BaseModel

from craft_grammar import create_grammar_model


class MyModel(BaseModel):
str_value: str
str_value_or_none: str | None
optional_str_value: Optional[str]
str_with_default: str = "string"
str_or_non_with_default: str | None = "string or None"
union_value: Union[str, int]
list_value: list[int] = []
other_list: List[int]
dict_value: dict[str, bool]
list_of_dicts: list[dict[str, str]]
literal_value: Literal["red", "green", "blue"] = "green"


EXPECTED_GRAMMAR_MODEL = """\
class GrammarMyModel(BaseModel):
str_value: Grammar[str]
str_value_or_none: Grammar[str] | None = None
optional_str_value: typing.Optional[str] = None
str_with_default: Grammar[str] = "string"
str_or_non_with_default: Grammar[str] | None = "string or None"
union_value: typing.Union[str, int]
list_value: Grammar[list[int]] = []
other_list: Grammar[typing.List[int]]
dict_value: Grammar[dict[str, bool]]
list_of_dicts: Grammar[list[dict[str, str]]]
literal_value: Grammar[str] = "green"
"""


def test_create_model():
grammar_model = create_grammar_model(MyModel)

assert grammar_model == EXPECTED_GRAMMAR_MODEL

0 comments on commit ead5a89

Please sign in to comment.