Implements persistent transclusion context for Information Spaces: - ScopedVariables: Variable scope layers (request > document > space) - SpaceTransclusionContext: Extends TransclusionContext with DB persistence - CrossSpaceResolver: Resolve references across space boundaries - ReferenceGraph: Track document dependencies for cache invalidation - PersistentReferenceGraph: Repository-backed reference tracking - RenderCache: Cache rendered output with invalidation support - CacheInvalidator: Event-driven cache invalidation using reference graph Key features: - Variable precedence: request overrides document overrides space - Reference tracking during transclusion processing - Transitive dependent calculation for cache invalidation - Event bus integration for automatic invalidation on content changes 47 unit tests covering all components. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
370 lines
12 KiB
Python
370 lines
12 KiB
Python
"""
|
|
Persistent transclusion context for Information Spaces.
|
|
|
|
This module extends the core TransclusionContext with database-backed
|
|
variable storage and space integration.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional, List, Set
|
|
from dataclasses import dataclass, field
|
|
|
|
from markitect.packaging.transclusion.context import TransclusionContext
|
|
from ..models import SpaceVariable
|
|
from ..repositories.interfaces import IVariableRepository
|
|
|
|
|
|
class VariableScope:
|
|
"""Defines variable scope levels in order of precedence (highest first)."""
|
|
|
|
REQUEST = "request" # Temporary, per-request variables
|
|
DOCUMENT = "document" # Document-level variables
|
|
SPACE = "space" # Space-level variables (persisted)
|
|
|
|
|
|
@dataclass
|
|
class ScopedVariables:
|
|
"""
|
|
Manages variables across multiple scope layers.
|
|
|
|
Variables are resolved in order: request > document > space
|
|
This allows local overrides of space-level defaults.
|
|
"""
|
|
|
|
space_vars: Dict[str, Any] = field(default_factory=dict)
|
|
document_vars: Dict[str, Any] = field(default_factory=dict)
|
|
request_vars: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
def get(self, name: str, default: Any = None) -> Any:
|
|
"""
|
|
Get a variable, checking scopes in order of precedence.
|
|
|
|
Args:
|
|
name: Variable name
|
|
default: Default if not found in any scope
|
|
|
|
Returns:
|
|
Variable value from highest precedence scope, or default
|
|
"""
|
|
if name in self.request_vars:
|
|
return self.request_vars[name]
|
|
if name in self.document_vars:
|
|
return self.document_vars[name]
|
|
if name in self.space_vars:
|
|
return self.space_vars[name]
|
|
return default
|
|
|
|
def set(self, name: str, value: Any, scope: str = VariableScope.REQUEST) -> None:
|
|
"""
|
|
Set a variable in the specified scope.
|
|
|
|
Args:
|
|
name: Variable name
|
|
value: Variable value
|
|
scope: Target scope (request, document, or space)
|
|
"""
|
|
if scope == VariableScope.REQUEST:
|
|
self.request_vars[name] = value
|
|
elif scope == VariableScope.DOCUMENT:
|
|
self.document_vars[name] = value
|
|
elif scope == VariableScope.SPACE:
|
|
self.space_vars[name] = value
|
|
|
|
def get_all(self) -> Dict[str, Any]:
|
|
"""
|
|
Get all variables merged with proper precedence.
|
|
|
|
Returns:
|
|
Dictionary with all variables, higher scopes overriding lower
|
|
"""
|
|
merged = {}
|
|
merged.update(self.space_vars)
|
|
merged.update(self.document_vars)
|
|
merged.update(self.request_vars)
|
|
return merged
|
|
|
|
def clear_scope(self, scope: str) -> None:
|
|
"""Clear all variables in a scope."""
|
|
if scope == VariableScope.REQUEST:
|
|
self.request_vars.clear()
|
|
elif scope == VariableScope.DOCUMENT:
|
|
self.document_vars.clear()
|
|
elif scope == VariableScope.SPACE:
|
|
self.space_vars.clear()
|
|
|
|
|
|
class SpaceTransclusionContext(TransclusionContext):
|
|
"""
|
|
Transclusion context integrated with Information Spaces.
|
|
|
|
Extends the base TransclusionContext with:
|
|
- Space-aware variable resolution with scope layers
|
|
- Reference tracking for cache invalidation
|
|
- Optional persistence of space-level variables
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
space_id: str,
|
|
base_path: Optional[Path] = None,
|
|
variables: Optional[Dict[str, Any]] = None,
|
|
max_depth: int = 10,
|
|
variable_repo: Optional[IVariableRepository] = None,
|
|
):
|
|
"""
|
|
Initialize a space-aware transclusion context.
|
|
|
|
Args:
|
|
space_id: The space ID this context belongs to
|
|
base_path: Base path for relative file resolution
|
|
variables: Initial request-level variables
|
|
max_depth: Maximum inclusion depth
|
|
variable_repo: Optional repository for persisting space variables
|
|
"""
|
|
# Initialize scoped vars BEFORE super().__init__() because
|
|
# the base class sets self.variables which triggers our property setter
|
|
self._scoped_vars = ScopedVariables()
|
|
self.space_id = space_id
|
|
self._variable_repo = variable_repo
|
|
|
|
# Track references during processing
|
|
self._current_document_id: Optional[str] = None
|
|
self._references: List[tuple] = [] # (source_doc_id, target_doc_id)
|
|
|
|
# Now call parent init (which may set variables via property)
|
|
super().__init__(base_path=base_path, variables={}, max_depth=max_depth)
|
|
|
|
# Load space variables from repository if available
|
|
if variable_repo:
|
|
self._load_space_variables()
|
|
|
|
# Set initial request variables
|
|
if variables:
|
|
for name, value in variables.items():
|
|
self._scoped_vars.set(name, value, VariableScope.REQUEST)
|
|
|
|
def _load_space_variables(self) -> None:
|
|
"""Load space-level variables from the repository."""
|
|
if not self._variable_repo:
|
|
return
|
|
|
|
space_vars = self._variable_repo.list_variables(self.space_id, scope="space")
|
|
for var in space_vars:
|
|
self._scoped_vars.set(var.name, var.value, VariableScope.SPACE)
|
|
|
|
doc_vars = self._variable_repo.list_variables(self.space_id, scope="document")
|
|
for var in doc_vars:
|
|
self._scoped_vars.set(var.name, var.value, VariableScope.DOCUMENT)
|
|
|
|
def set_current_document(self, document_id: str) -> None:
|
|
"""
|
|
Set the current document being processed.
|
|
|
|
Args:
|
|
document_id: The document ID
|
|
"""
|
|
self._current_document_id = document_id
|
|
|
|
def get_current_document(self) -> Optional[str]:
|
|
"""Get the current document being processed."""
|
|
return self._current_document_id
|
|
|
|
def track_reference(self, target_doc_id: str) -> None:
|
|
"""
|
|
Track a reference from current document to target.
|
|
|
|
Args:
|
|
target_doc_id: The document being referenced
|
|
"""
|
|
if self._current_document_id:
|
|
self._references.append((self._current_document_id, target_doc_id))
|
|
|
|
def get_tracked_references(self) -> List[tuple]:
|
|
"""
|
|
Get all tracked references.
|
|
|
|
Returns:
|
|
List of (source_doc_id, target_doc_id) tuples
|
|
"""
|
|
return list(self._references)
|
|
|
|
def clear_tracked_references(self) -> None:
|
|
"""Clear all tracked references."""
|
|
self._references.clear()
|
|
|
|
# Override variable methods to use scoped storage
|
|
|
|
def set_variable(self, name: str, value: Any, scope: str = VariableScope.REQUEST) -> None:
|
|
"""
|
|
Set a variable in the specified scope.
|
|
|
|
Args:
|
|
name: Variable name
|
|
value: Variable value
|
|
scope: Variable scope (request, document, or space)
|
|
"""
|
|
self._scoped_vars.set(name, value, scope)
|
|
|
|
# Persist space-level variables if repository available
|
|
if scope == VariableScope.SPACE and self._variable_repo:
|
|
var = SpaceVariable(
|
|
space_id=self.space_id,
|
|
name=name,
|
|
value=value,
|
|
scope=scope,
|
|
)
|
|
self._variable_repo.set_variable(var)
|
|
|
|
def get_variable(self, name: str, default: Any = None) -> Any:
|
|
"""
|
|
Get a variable from the scoped storage.
|
|
|
|
Args:
|
|
name: Variable name
|
|
default: Default value if not found
|
|
|
|
Returns:
|
|
Variable value or default
|
|
"""
|
|
return self._scoped_vars.get(name, default)
|
|
|
|
def substitute_variables(self, text: str) -> str:
|
|
"""
|
|
Substitute variables in text using scoped variable resolution.
|
|
|
|
Args:
|
|
text: Text containing {{variable}} references
|
|
|
|
Returns:
|
|
Text with variables substituted
|
|
"""
|
|
import re
|
|
|
|
def replace_var(match):
|
|
var_name = match.group(1).strip()
|
|
value = self._scoped_vars.get(var_name)
|
|
return str(value) if value is not None else match.group(0)
|
|
|
|
return re.sub(r'\{\{([^}]+)\}\}', replace_var, text)
|
|
|
|
@property
|
|
def variables(self) -> Dict[str, Any]:
|
|
"""Get all variables merged with proper precedence."""
|
|
return self._scoped_vars.get_all()
|
|
|
|
@variables.setter
|
|
def variables(self, value: Dict[str, Any]) -> None:
|
|
"""Set request-level variables."""
|
|
self._scoped_vars.request_vars = value
|
|
|
|
def create_child_context(
|
|
self, new_base_path: Optional[Path] = None
|
|
) -> "SpaceTransclusionContext":
|
|
"""
|
|
Create a child context for nested processing.
|
|
|
|
Args:
|
|
new_base_path: New base path for the child context
|
|
|
|
Returns:
|
|
New SpaceTransclusionContext with inherited state
|
|
"""
|
|
child = SpaceTransclusionContext(
|
|
space_id=self.space_id,
|
|
base_path=new_base_path or self.base_path,
|
|
max_depth=self.max_depth,
|
|
variable_repo=self._variable_repo,
|
|
)
|
|
|
|
# Copy scoped variables
|
|
child._scoped_vars.space_vars = self._scoped_vars.space_vars.copy()
|
|
child._scoped_vars.document_vars = self._scoped_vars.document_vars.copy()
|
|
child._scoped_vars.request_vars = self._scoped_vars.request_vars.copy()
|
|
|
|
# Copy processing state
|
|
child.current_depth = self.current_depth
|
|
child.inclusion_stack = self.inclusion_stack.copy()
|
|
child.processed_files = self.processed_files.copy()
|
|
|
|
# Share reference tracking
|
|
child._current_document_id = self._current_document_id
|
|
child._references = self._references # Shared list
|
|
|
|
return child
|
|
|
|
|
|
class CrossSpaceResolver:
|
|
"""
|
|
Resolves references across space boundaries.
|
|
|
|
Enables transclusion from one space to reference content in another space.
|
|
"""
|
|
|
|
def __init__(self, contexts: Dict[str, SpaceTransclusionContext]):
|
|
"""
|
|
Initialize the cross-space resolver.
|
|
|
|
Args:
|
|
contexts: Dictionary mapping space_id to SpaceTransclusionContext
|
|
"""
|
|
self._contexts = contexts
|
|
|
|
def add_context(self, space_id: str, context: SpaceTransclusionContext) -> None:
|
|
"""Add a space context."""
|
|
self._contexts[space_id] = context
|
|
|
|
def get_context(self, space_id: str) -> Optional[SpaceTransclusionContext]:
|
|
"""Get the context for a space."""
|
|
return self._contexts.get(space_id)
|
|
|
|
def resolve_variable(
|
|
self,
|
|
space_id: str,
|
|
var_name: str,
|
|
default: Any = None,
|
|
) -> Any:
|
|
"""
|
|
Resolve a variable from a specific space.
|
|
|
|
Args:
|
|
space_id: The space to look up
|
|
var_name: Variable name
|
|
default: Default value if not found
|
|
|
|
Returns:
|
|
Variable value or default
|
|
"""
|
|
context = self._contexts.get(space_id)
|
|
if context:
|
|
return context.get_variable(var_name, default)
|
|
return default
|
|
|
|
def resolve_cross_space_reference(
|
|
self,
|
|
reference: str,
|
|
current_space_id: str,
|
|
) -> Optional[tuple]:
|
|
"""
|
|
Parse and resolve a cross-space reference.
|
|
|
|
Reference format: "space:other-space/path/to/doc.md"
|
|
or just "path/to/doc.md" for current space.
|
|
|
|
Args:
|
|
reference: The reference string
|
|
current_space_id: Current space ID for relative references
|
|
|
|
Returns:
|
|
Tuple of (space_id, path) or None if invalid
|
|
"""
|
|
if ":" in reference and reference.startswith("space:"):
|
|
# Cross-space reference
|
|
_, rest = reference.split(":", 1)
|
|
if "/" in rest:
|
|
space_name, path = rest.split("/", 1)
|
|
return (space_name, "/" + path)
|
|
return None
|
|
else:
|
|
# Same-space reference
|
|
return (current_space_id, reference)
|