Dict-like model system with schema validation, derived fields, and inheritance for the Grimoire tabletop RPG engine.
Grimoire Model provides a sophisticated, schema-driven model system that combines the familiar dict-like interface with powerful features like automatic field derivation, template-based expressions, model inheritance, and comprehensive validation. Designed to integrate seamlessly with grimoire-context for complete game state management.
- π Dict-like Interface: Familiar Python dictionary operations with schema validation
- β¨ Dual Access Pattern: Support for both dictionary-style (
obj['field']) and attribute-style (obj.field) access - π Reactive Derived Fields: Automatic computation with dependency tracking and batch updates
- 𧬠Model Inheritance: Multiple inheritance support with automatic namespace-based resolution
- π Template Expressions: Jinja2-powered field templates for dynamic content
- π¨ Template Engine Compatible: Works seamlessly with Jinja2, Django templates, and other engines
- π·οΈ Namespace Organization: Global model registry with namespace-based organization
- π‘οΈ Schema Validation: Pydantic-based type checking and custom validation rules
- π§ Dependency Injection: Pluggable resolvers for extensibility
- β‘ Performance Optimized: Efficient batch updates and lazy evaluation
- π― grimoire-context Integration: Seamless interoperability with context management
pip install grimoire-modelfrom grimoire_model import ModelDefinition, AttributeDefinition, create_model
# Define a character model schema
character_def = ModelDefinition(
id="character",
name="Player Character",
namespace="rpg", # Organize models in namespaces
attributes={
"name": AttributeDefinition(type="str", required=True),
"level": AttributeDefinition(type="int", default=1),
"hp": AttributeDefinition(type="int", default=100),
"mp": AttributeDefinition(type="int", default=50),
# Derived fields automatically update when dependencies change
"max_hp": AttributeDefinition(
type="int",
derived="{{ level * 8 + hp }}"
),
"character_summary": AttributeDefinition(
type="str",
derived="Level {{ level }} {{ name }} ({{ max_hp }} HP, {{ mp }} MP)"
)
}
)
# Create a character instance
character = create_model(character_def, {
"name": "Aragorn",
"level": 15,
"hp": 120,
"mp": 80
})
# Access data using BOTH dictionary-style AND attribute-style notation
print(character['name']) # "Aragorn" (dictionary-style)
print(character.name) # "Aragorn" (attribute-style)
print(character.max_hp) # 240 (15 * 8 + 120)
print(character.character_summary) # "Level 15 Aragorn (240 HP, 80 MP)"
# Updates work with both access patterns
character['level'] = 20 # Dictionary-style update
character.level = 20 # Attribute-style update (same result)
print(character.max_hp) # 280 (automatically recalculated)Register domain-specific primitive types that should be treated as primitive values rather than complex model objects:
from grimoire_model import register_primitive_type, ModelDefinition, AttributeDefinition, create_model_without_validation
# Register custom primitive types
register_primitive_type('roll') # Dice roll notation
register_primitive_type('duration') # Time periods
register_primitive_type('distance') # Measurements
# Define a model using custom primitive types
weapon_def = ModelDefinition(
id='weapon',
name='Weapon',
attributes={
'name': AttributeDefinition(type='str', required=True),
'damage': AttributeDefinition(type='roll', required=True) # Custom primitive
}
)
# Create model - custom primitives work like built-in types
weapon = create_model_without_validation(weapon_def, {
'name': 'Longsword',
'damage': '1d8' # Stored as-is, like a string
})
print(weapon['damage']) # '1d8'Custom primitive types:
- Are stored as raw values without instantiation
- Don't require model registration
- Can have optional validators
- Support domain-specific type semantics
- Work in derived field templates
Models are automatically registered in a global registry using namespaces:
from grimoire_model import get_model
# Models auto-register when created
character_def = ModelDefinition(
id="character",
namespace="rpg", # Registered in "rpg" namespace
# ... attributes ...
)
# Retrieve from anywhere in your application
retrieved_def = get_model("rpg", "character")
new_character = create_model(retrieved_def, {"name": "Hero"})
# Perfect for inheritance - child models automatically find parents
base_def = ModelDefinition(id="base", namespace="rpg", ...)
child_def = ModelDefinition(id="child", namespace="rpg", extends=["base"], ...)
# No manual registry needed - inheritance resolves automatically!from grimoire_model import get_model, clear_registry
# Base entity definition (auto-registered in namespace)
base_entity_def = ModelDefinition(
id="base_entity",
name="Base Entity",
namespace="game", # Registered in "game" namespace
attributes={
"id": AttributeDefinition(type="str", required=True),
"name": AttributeDefinition(type="str", required=True),
"description": AttributeDefinition(type="str", default="")
}
)
# Character extends base entity (automatic inheritance resolution)
character_def = ModelDefinition(
id="character",
name="Character",
namespace="game", # Same namespace enables automatic inheritance
extends=["base_entity"], # Automatically finds base_entity in namespace
attributes={
"level": AttributeDefinition(type="int", default=1),
"hp": AttributeDefinition(type="int", default=100),
"max_hp": AttributeDefinition(
type="int",
derived="{{ level * 8 + hp }}"
)
}
)
# Create character with inherited fields (no registry needed!)
character = create_model(
character_def,
{
"id": "char_001", # From base_entity
"name": "Legolas", # From base_entity
"description": "Elf archer", # From base_entity
"level": 12, # From character
"hp": 96 # From character
}
)
print(character['id']) # "char_001" (inherited)
print(character['name']) # "Legolas" (inherited)
print(character['max_hp']) # 192 (derived field)
# Retrieve models from global registry
retrieved_char_def = get_model("game", "character")
another_character = create_model(retrieved_char_def, {
"id": "char_002",
"name": "Gimli"
})from grimoire_context import GrimoireContext
# Create context with character model
context = GrimoireContext({
'party': {
'leader': character,
'members': 4
}
})
# Modify character through context - derived fields update automatically
context = context.set_variable('party.leader.level', 25)
updated_character = context.get_variable('party.leader')
print(updated_character['level']) # 25
print(updated_character['max_hp']) # 296 (automatically recalculated)# Batch multiple changes for better performance
character.batch_update({
'level': 30,
'hp': 150,
'mp': 120
})
# All derived fields updated once after batch completion
print(character['max_hp']) # 390 (30 * 8 + 150)GrimoireModel objects support both dictionary-style and attribute-style access, making them fully compatible with template engines like Jinja2, Django templates, and others:
from jinja2 import Template
# Create a weapon model
weapon_def = ModelDefinition(
id="weapon",
name="Weapon",
attributes={
"name": AttributeDefinition(type="str", required=True),
"damage": AttributeDefinition(type="str", required=True),
"bonus": AttributeDefinition(type="int", default=0),
}
)
weapon = create_model(weapon_def, {
"name": "Longsword",
"damage": "1d8",
"bonus": 2
})
# Use attribute access in Jinja2 templates
template = Template("{{ weapon.name }}: {{ weapon.damage }} +{{ weapon.bonus }}")
result = template.render(weapon=weapon)
print(result) # "Longsword: 1d8 +2"
# Works with more complex templates
template = Template("""
{% if weapon.bonus > 0 %}
{{ weapon.name }} ({{ weapon.damage }}+{{ weapon.bonus }})
{% else %}
{{ weapon.name }} ({{ weapon.damage }})
{% endif %}
""")This dual-access pattern (dictionary and attribute) provides:
- Template Compatibility: Works seamlessly with Jinja2, Django, and other template engines
- Standard Python Behavior: Objects behave like normal Python objects
- IDE Support: Better autocomplete and type hints
- Backward Compatible: All existing dictionary-style code continues to work
- Logging Configuration - Configure library logging output and integration
Model definitions are schemas that describe the structure, types, and behavior of your data:
model_def = ModelDefinition(
id="weapon",
name="Weapon",
namespace="combat", # Organize in combat namespace
description="Combat weapon with damage calculations",
attributes={
"name": AttributeDefinition(type="str", required=True),
"base_damage": AttributeDefinition(type="int", default=1, range="1..50"),
"enhancement": AttributeDefinition(type="int", default=0, range="0..10"),
# Derived field with complex logic
"total_damage": AttributeDefinition(
type="int",
derived="{{ base_damage + enhancement * 2 }}"
),
"damage_category": AttributeDefinition(
type="str",
derived="{% if total_damage >= 20 %}High{% elif total_damage >= 10 %}Medium{% else %}Low{% endif %}"
)
},
validations=[
ValidationRule(
expression="base_damage > 0",
message="Base damage must be positive"
)
]
)Use Jinja2 templates for powerful derived field logic:
# Simple expression
"max_hp": "{{ level * 8 + constitution * 2 }}"
# Conditional logic
"damage_bonus": "{% if strength >= 15 %}{{ (strength - 10) // 2 }}{% else %}0{% endif %}"
# Complex calculations
"skill_modifier": "{{ (skill_level + attribute_bonus - 10) // 2 }}"Add custom validation logic to ensure data integrity:
ValidationRule(
expression="level >= 1 and level <= 100",
message="Character level must be between 1 and 100"
),
ValidationRule(
expression="hp > 0 or status == 'dead'",
message="Living characters must have positive HP"
)ModelDefinition(
id: str, # Unique model identifier
name: str, # Human-readable name
namespace: str = "default", # Namespace for organization and inheritance
description: str = "", # Model description
attributes: Dict[str, AttributeDefinition], # Field definitions
extends: List[str] = None, # Parent model IDs (resolved in namespace)
validations: List[ValidationRule] = None # Validation rules
)AttributeDefinition(
type: str, # Data type (str, int, float, bool, list, dict, or custom primitive)
required: bool = False, # Whether field is required
default: Any = None, # Default value
derived: str = None, # Template expression for derived fields
range: str = None, # Value range constraint (e.g., "1..100")
enum: List[Any] = None, # Allowed values
pattern: str = None, # Regex pattern for strings
description: str = "" # Field description
)class GrimoireModel(MutableMapping):
def __init__(
self,
model_definition: ModelDefinition,
data: Dict[str, Any] = None,
template_resolver: TemplateResolver = None,
derived_field_resolver: DerivedFieldResolver = None,
**kwargs
)
# Dict-like interface
def __getitem__(self, key: str) -> Any
def __setitem__(self, key: str, value: Any) -> None
def __delitem__(self, key: str) -> None
def __iter__(self) -> Iterator[str]
def __len__(self) -> int
def keys(), values(), items()
# Attribute-style access (NEW in 0.3.2)
def __getattr__(self, name: str) -> Any
def __setattr__(self, name: str, value: Any) -> None
# Enables: obj.field_name (read) and obj.field_name = value (write)
# Batch operations
def batch_update(self, updates: Dict[str, Any]) -> None
# Path operations (dot notation)
def get(self, path: str, default: Any = None) -> Any
def set(self, path: str, value: Any) -> None
def has(self, path: str) -> bool
def delete(self, path: str) -> Nonedef create_model(
model_definition: ModelDefinition,
data: Dict[str, Any] = None,
template_resolver_type: str = "jinja2",
derived_field_resolver_type: str = "batched",
**kwargs
) -> GrimoireModelCreates a model instance with default resolvers. Inheritance is automatically resolved from the global model registry using namespaces.
from grimoire_model import register_model, get_model, clear_registry
# Register model manually (usually automatic)
register_model("my_namespace", "my_model", model_definition)
# Retrieve model from registry
model_def = get_model("my_namespace", "my_model")
# Clear all models (useful for testing)
clear_registry()
# Access registry directly for advanced operations
from grimoire_model import get_model_registry
registry = get_model_registry()
registry_dict = registry.get_registry_dict()
all_namespaces = registry.list_namespaces()from grimoire_model import (
register_primitive_type,
unregister_primitive_type,
is_primitive_type,
clear_primitive_registry
)
# Register a custom primitive type
register_primitive_type('roll')
# Register with optional validator
def validate_duration(value):
if isinstance(value, str) and value.endswith('s'):
return True, None
return False, "Duration must end with 's'"
register_primitive_type('duration', validator=validate_duration)
# Check if a type is registered as primitive
is_primitive_type('roll') # True
is_primitive_type('unknown') # False
# Unregister a primitive type
unregister_primitive_type('roll')
# Clear all registered primitives (useful for testing)
clear_primitive_registry()Jinja2TemplateResolver: Standard Jinja2 template syntaxModelContextTemplateResolver: Simple$variablesubstitutionCachingTemplateResolver: Cached template compilation for performance
BatchedDerivedFieldResolver: Batches updates for performanceDerivedFieldResolver: Immediate update resolver
git clone https://github.com/wyrdbound/grimoire-model.git
cd grimoire-model
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install -e ".[dev]"# Run all tests with coverage
/Users/justingaylor/src/grimoire-model/.venv/bin/python -m pytest --cov=grimoire_model --cov-report=term
# Run specific test file
/Users/justingaylor/src/grimoire-model/.venv/bin/python -m pytest tests/test_model.py
# Run with verbose output
/Users/justingaylor/src/grimoire-model/.venv/bin/python -m pytest -v
# Generate HTML coverage report
/Users/justingaylor/src/grimoire-model/.venv/bin/python -m pytest --cov=grimoire_model --cov-report=html
# Open htmlcov/index.html in browserNote: Use the virtual environment in the project root as specified in the development guidelines.
# Install development dependencies
source .venv/bin/activate && pip install ruff mypy
# Linting and formatting
source .venv/bin/activate && ruff check .
source .venv/bin/activate && ruff format .
# Type checking
source .venv/bin/activate && mypy src/grimoire_model/
# Run all quality checks
source .venv/bin/activate && ruff check . && mypy src/grimoire_model/# Basic usage example
source .venv/bin/activate && python examples/01_basic_usage.py
# Advanced features and inheritance
source .venv/bin/activate && python examples/02_advanced_usage.py
# Inheritance and polymorphism
source .venv/bin/activate && python examples/03_inheritance_polymorphism.py
# Performance and integration testing
source .venv/bin/activate && python examples/04_performance_integration.py- Python 3.8+
- pydantic >= 2.0.0
- pyrsistent >= 0.19.0
- jinja2 >= 3.1.0
- pyyaml >= 6.0
- pytest >= 7.0.0
- pytest-cov >= 4.0.0
- pytest-mock >= 3.0.0
- hypothesis >= 6.0.0
- mypy >= 1.0.0
- ruff >= 0.1.0
Grimoire Model excels in scenarios requiring structured, validated data with complex relationships:
- RPG Character Systems: Stats, levels, equipment with derived values
- Game Item Management: Equipment, inventory, crafting systems
- Rule Engine Data: Complex game mechanics with interdependent calculations
- Configuration Systems: Hierarchical configs with inheritance and validation
- Dynamic Content: Template-based content generation with context awareness
The package follows clean architecture principles with clear separation of concerns:
- Core Layer: Model definitions, schemas, and the main GrimoireModel class
- Resolver Layer: Pluggable template and derived field resolution systems
- Validation Layer: Type checking, constraints, and custom validation rules
- Utils Layer: Inheritance resolution, path utilities, and helper functions
- Integration Layer: grimoire-context compatibility and factory functions
- Dependency Injection: All major components can be swapped via constructor injection
- Immutable Operations: Uses pyrsistent for efficient immutable data structures
- Template-Driven: Jinja2 templates provide powerful expression capabilities
- Performance-Focused: Batch updates and lazy evaluation minimize overhead
- Type Safety: Full type hints and Pydantic integration for runtime validation
- Explicit Errors: Prefers explicit errors over fallbacks to maintain system stability
Current benchmarks (86% test coverage, 184 tests passing):
- Model Creation: ~1ms for simple models, ~5ms for complex inheritance
- Field Updates: ~0.1ms for direct fields, ~2ms for derived field cascades
- Batch Updates: 50-80% faster than individual updates for multiple fields
- Memory Usage: ~50KB per model instance (excluding data)
- Template Resolution: Cached compilation provides 10x speed improvement
Seamless integration is automatically enabled when both packages are installed:
from grimoire_model import create_model, ModelDefinition, AttributeDefinition
from grimoire_context import GrimoireContext
# Models work naturally in contexts
character = create_model(character_def, character_data)
context = GrimoireContext({'player': character})
# Context operations automatically handle model updates
updated_context = context.set_variable('player.level', 25)
updated_character = updated_context['player']
# Derived fields update automatically
print(updated_character['max_hp']) # Recalculated based on new levelThe package provides a comprehensive exception hierarchy:
from grimoire_model import (
GrimoireModelError, # Base exception
ModelValidationError, # Validation failures
TemplateResolutionError, # Template processing errors
InheritanceError, # Model inheritance issues
DependencyError, # Derived field dependency issues
ConfigurationError # Setup and configuration errors
)
try:
character = create_model(character_def, invalid_data)
except ModelValidationError as e:
print(f"Validation failed: {e}")
print(f"Field: {e.field_name}")
print(f"Value: {e.field_value}")
print(f"Validation rule: {e.validation_rule}")from grimoire_model.resolvers.template import TemplateResolver
class CustomTemplateResolver(TemplateResolver):
def resolve_template(self, template: str, context: dict) -> str:
# Custom template logic
return processed_template
# Use custom resolver
model = GrimoireModel(
model_def,
data,
template_resolver=CustomTemplateResolver()
)from grimoire_model.validation.validators import ValidationEngine
def custom_validator(value, rule_params):
# Custom validation logic
return is_valid, error_message
# Register custom validator
engine = ValidationEngine()
engine.register_validator("custom_rule", custom_validator)# Multiple parent models (all in same namespace)
combat_def = ModelDefinition(
id="character",
namespace="game", # All parent models must be in same namespace
extends=["base_entity", "combatant", "spell_caster"],
attributes={...}
)
# Automatic conflict resolution with left-to-right precedence
# Parents automatically resolved from "game" namespaceThis project is licensed under the MIT License. See the LICENSE file for complete terms and conditions.
Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
If you have questions about the project, please contact: wyrdbound@proton.me
Copyright (c) 2025 The Wyrd One