diff --git a/craft_grammar/__init__.py b/craft_grammar/__init__.py
index 586b0d5..3aa58eb 100644
--- a/craft_grammar/__init__.py
+++ b/craft_grammar/__init__.py
@@ -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",
@@ -38,4 +39,5 @@
"Statement",
"ToStatement",
"TryStatement",
+ "create_grammar_model",
]
diff --git a/craft_grammar/create.py b/craft_grammar/create.py
new file mode 100644
index 0000000..231e718
--- /dev/null
+++ b/craft_grammar/create.py
@@ -0,0 +1,152 @@
+# 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 .
+"""Utilities to create grammar models."""
+import builtins
+import logging
+import types
+import typing
+
+from pydantic import BaseModel
+
+logger = logging.getLogger(__name__)
+
+CONFIG_TEMPLATE = """
+ class Config:
+ validate_assignment = True
+ extra = "ignore"
+ allow_mutation = False
+ alias_generator = lambda s: s.replace("_", "-")
+"""
+
+
+def create_grammar_model(model_class: typing.Type[BaseModel]) -> str:
+ """Create the code 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)
+
+ if grammar_type is None:
+ logger.debug(
+ "Skipping unknown type %s for attribute %s", attr_type, attr_name
+ )
+ continue
+
+ attr_field = model_class.__fields__[attr_name]
+ alias = attr_field.alias
+ new_name = alias if "-" not in alias else attr_name
+ attr_decl = f"{new_name}: {grammar_type}"
+
+ if not attr_field.required:
+ default_factory = attr_field.default_factory
+ if default_factory is not None:
+ default = repr(default_factory())
+ else:
+ 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] + CONFIG_TEMPLATE.split("\n")
+ for attr in attributes:
+ lines.append(f" {attr}")
+ lines.append("") # Final newline
+
+ return "\n".join(lines)
+
+
+def _get_grammar_type_for(model_type) -> str | None: # pylint: disable=R0911,R0912
+ """Get the "grammar" type for ``model_type``.
+
+ Returns None if we don't know how to "grammify" ``model_type``.
+ """
+ if model_type is type(None):
+ # None -> None
+ return "None"
+
+ origin = typing.get_origin(model_type)
+ args = typing.get_args(model_type)
+
+ match origin:
+ case None:
+ # Primitive, regular class, Pydantic model, etc.
+ if issubclass(model_type, BaseModel):
+ # PydanticModel -> GrammarPydanticModel
+ # (assumes that generate_grammar_model(model_type) will be called).
+ return f"Grammar{model_type.__name__}"
+ return f"Grammar[{model_type.__name__}]"
+
+ case typing.Union:
+ # Type is either a Union[] or an Optional[]
+ if len(args) == 2 and type(None) in args:
+ # Type is an Optional[]
+ # Optional[T] -> Optional[Grammar[T]]
+ other_type = [t for t in args if t is not type(None)][0]
+ grammar_type = _get_grammar_type_for(other_type)
+ return f"Optional[{grammar_type}]"
+
+ # Union[X, Y] -> Grammar[Union[X, Y]]
+ union_args = []
+ for arg in typing.get_args(model_type):
+ if typing.get_origin(arg) is None:
+ if arg is type(None):
+ name = "None"
+ else:
+ # print int as "int"
+ name = arg.__name__
+ union_args.append(name)
+ else:
+ # print dict[k, v] as "dict[k,v]"
+ union_args.append(str(arg))
+ comma_args = ", ".join(union_args)
+ return f"Grammar[Union[{comma_args}]]"
+
+ case types.UnionType:
+ # Type is an expression like "str | int | None"
+ # A type like "str | None" becomes "Grammar[str] | None"
+ grammar_types = [_get_grammar_type_for(a) for a in args]
+ grammar_args = [t for t in grammar_types if t is not None]
+ return " | ".join(grammar_args)
+
+ case builtins.list | builtins.dict:
+ # list[T] -> Grammar[list[T]]
+ name = str(model_type).removeprefix("typing.")
+ return f"Grammar[{name}]"
+
+ case typing.Literal:
+ # Literal["a", "b"] -> Grammar[str]
+ arg_types = set(type(a) for a in typing.get_args(model_type))
+ if len(arg_types) == 1:
+ # For now only handle the case where all possible literal values
+ # have the same type
+ arg_type = arg_types.pop()
+ return _get_grammar_type_for(arg_type)
+ return None
+
+ case _:
+ return None
diff --git a/setup.cfg b/setup.cfg
index b55f675..a37a9e2 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -29,6 +29,7 @@ packages = find:
zip_safe = False
install_requires =
overrides
+ pydantic<2.0
[options.package_data]
craft_grammar = py.typed
@@ -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
diff --git a/tests/unit/test_create.py b/tests/unit/test_create.py
new file mode 100644
index 0000000..73d2b7c
--- /dev/null
+++ b/tests/unit/test_create.py
@@ -0,0 +1,90 @@
+# 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 .
+from __future__ import annotations
+
+from typing import List, Literal, Optional, Union
+
+from pydantic import BaseModel, Field
+
+from craft_grammar import create_grammar_model
+
+
+class SubModel(BaseModel):
+ """A Pydantic model used as an attribute of another model."""
+
+
+class MyModel(BaseModel):
+ """A Pydantic model that we want to "grammify"."""
+
+ # Primitive types
+ 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, None]
+ literal_value: Literal["red", "green", "blue"] = "green"
+
+ # Collections
+ list_value: list[int] = []
+ other_list: List[int]
+ dict_value: dict[str, bool]
+ list_of_dicts: list[dict[str, str]]
+
+ # Pydantic models
+ sub_model: SubModel
+
+ # An aliased field (should use the alias)
+ aliased_field: int = Field(default=1, alias="alias_name")
+
+ # A field with a default factory
+ factory_field: str = Field(default_factory=str)
+
+ # Untyped fields are ignored
+ untyped_1 = "untyped_1"
+ untyped_2 = 1
+
+
+EXPECTED_GRAMMAR_MODEL = """\
+class GrammarMyModel(BaseModel):
+
+ class Config:
+ validate_assignment = True
+ extra = "ignore"
+ allow_mutation = False
+ alias_generator = lambda s: s.replace("_", "-")
+
+ str_value: Grammar[str]
+ str_value_or_none: Grammar[str] | None = None
+ optional_str_value: Optional[Grammar[str]] = None
+ str_with_default: Grammar[str] = "string"
+ str_or_non_with_default: Grammar[str] | None = "string or None"
+ union_value: Grammar[Union[str, int, None]] = None
+ literal_value: Grammar[str] = "green"
+ list_value: Grammar[list[int]] = []
+ other_list: Grammar[List[int]]
+ dict_value: Grammar[dict[str, bool]]
+ list_of_dicts: Grammar[list[dict[str, str]]]
+ sub_model: GrammarSubModel
+ alias_name: Grammar[int] = 1
+ factory_field: Grammar[str] = ""
+"""
+
+
+def test_create_model():
+ grammar_model = create_grammar_model(MyModel)
+
+ assert grammar_model == EXPECTED_GRAMMAR_MODEL