Developer Guide#

This guide explains how the Cannula code generation system works internally for developers who want to modify or extend the code generation capabilities.

Architecture Overview#

The code generation system consists of several key components:

  1. Schema Analysis Layer (schema_analyzer.py)
    • Processes GraphQL schema and metadata

    • Creates intermediate representations

    • Handles relationships and forward references

  2. Code Generation Layer
    • Base CodeGenerator class

    • Specialized generators for different outputs:
      1. PythonCodeGenerator for type definitions

      2. SQLAlchemyGenerator for database models

      3. ContextGenerator for data source classes

  3. Parsing Layer
    • parse_type.py - GraphQL type parsing

    • parse_args.py - Argument parsing

Data Flow#

GraphQL Schema + Metadata
        ↓
SchemaAnalyzer
        ↓
ObjectTypes, Fields, etc.
        ↓
CodeGenerators
        ↓
AST Generation
        ↓
Final Code

Schema Analysis#

The SchemaAnalyzer class is the entry point for processing schemas:

class SchemaAnalyzer:
    def __init__(self, schema: GraphQLSchema):
        self.schema = schema
        self.extensions = SchemaExtension(schema)
        self._analyze()

Key responsibilities:

  • Categorizing types (objects, interfaces, unions, etc.)

  • Processing metadata

  • Handling relationships and forward references

  • Creating intermediate representations

Type System#

The system uses several intermediate representations:

  1. ObjectType
    • Represents GraphQL object types

    • Holds fields and metadata

    • Tracks relationships

  2. Field
    • Represents GraphQL fields

    • Contains type information

    • Holds arguments and metadata

  3. FieldType
    • Represents field types

    • Handles lists and nullability

    • Manages type references

Code Generation Base#

The CodeGenerator base class provides common functionality:

class CodeGenerator(ABC):
    def __init__(self, analyzer: SchemaAnalyzer):
        self.analyzer = analyzer
        self.schema = analyzer.schema
        self.imports = analyzer.extensions.imports

    @abstractmethod
    def generate(self, *args, **kwargs) -> str:
        pass

Key features:

  • Import management

  • Access to analyzed schema

  • AST generation helpers

AST Generation#

Code generators create Python AST nodes which are then formatted into code. Common patterns:

  1. Class Generation

ast.ClassDef(
    name=type_info.py_type,
    bases=[ast_for_name("BaseModel")],
    keywords=[],
    body=body,
    decorator_list=decorators,
)
  1. Field Generation

ast_for_annotation_assignment(
    self.name,
    annotation=ast_for_name(self.type),
    default=default
)

Extending the System#

To add new code generation capabilities:

  1. New Generator

    Create a new subclass of CodeGenerator:

    class MyGenerator(CodeGenerator):
        def generate(self) -> str:
            body: List[ast.stmt] = []
            # Add AST nodes to body
            module = self.create_module(body)
            return format_code(module)
    
  2. New Metadata

    Add handling in SchemaExtension:

    class SchemaExtension:
        def __init__(self, schema: GraphQLSchema):
            self._my_metadata = schema.extensions.get("my_metadata", {})
    
  3. New Type Categories

    Extend SchemaAnalyzer:

    class SchemaAnalyzer:
        def _analyze(self):
            self.my_types: List[MyType] = []
            # Process types...
    

Best Practices#

  1. Type Handling
    • Always use parse_graphql_type for type processing

    • Handle nullability consistently

    • Consider forward references

  2. Metadata Processing
    • Validate metadata early

    • Provide clear error messages

    • Handle missing metadata gracefully

  3. AST Generation
    • Use utility functions in utils.py

    • Keep AST construction clean and organized

    • Handle imports carefully

  4. Testing
    • Add tests in test_codegen.py

    • Test edge cases and error conditions

    • Verify generated code validity

Common Tasks#

  1. Adding a New Field Metadata Option

# In SchemaAnalyzer
def get_field(self, field_name: str, ...):
    metadata = extensions.get_field_metadata(...)
    # Handle new metadata
    new_option = metadata.get("new_option")

# In Generator
def create_field_definition(self, field: Field):
    if new_option := field.metadata.get("new_option"):
        # Generate appropriate AST
  1. Adding a New Type Category

# Create type class
@dataclasses.dataclass
class NewType:
    name: str
    # ...

# Add to SchemaAnalyzer
def _analyze(self):
    self.new_types: List[NewType] = []
    for name, type_def in self.schema.type_map.items():
        if is_new_type(type_def):
            self.new_types.append(self.parse_new_type(type_def))
  1. Modifying Code Generation

class MyGenerator(CodeGenerator):
    def render_object_type(self, type_info: ObjectType):
        # Custom AST generation
        return [
            ast.ClassDef(
                name=type_info.py_type,
                # ...
            )
        ]

Error Handling#

The system uses custom exceptions for schema validation:

class SchemaValidationError(Exception):
    """Raised when schema validation fails"""
    pass

Key validation points:

  • Field nullability conflicts

  • Invalid relationships

  • Missing required metadata

  • Type reference issues

Development Workflow#

  1. Make changes to code generation

  2. Run tests: make test

  3. Generate sample code to verify changes

  4. Update documentation if needed

  5. Add new tests for changes

Contributing#

When contributing changes:

  1. Follow the existing code style

  2. Add appropriate tests

  3. Update documentation

  4. Handle edge cases

  5. Consider backward compatibility

Further Reading#

  • GraphQL AST documentation

  • Python AST module documentation

  • SQLAlchemy relationship documentation

  • Pydantic model documentation