Files
markitect-main/markitect/spaces/transclusion/persistent_context.py
tegwick 7da77396a9 feat(spaces): implement Phase 3 Persistent Transclusion Context
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>
2026-02-08 08:36:50 +01:00

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)