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>
This commit is contained in:
@@ -6,7 +6,40 @@ This package extends the existing TransclusionContext with:
|
|||||||
- Cross-space reference resolution
|
- Cross-space reference resolution
|
||||||
- Reference graph for dependency tracking
|
- Reference graph for dependency tracking
|
||||||
- Variable scope layers (space, document, request)
|
- Variable scope layers (space, document, request)
|
||||||
|
- Event-driven cache invalidation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Transclusion extensions will be implemented in Phase 3
|
from .persistent_context import (
|
||||||
__all__ = []
|
SpaceTransclusionContext,
|
||||||
|
ScopedVariables,
|
||||||
|
VariableScope,
|
||||||
|
CrossSpaceResolver,
|
||||||
|
)
|
||||||
|
from .reference_graph import (
|
||||||
|
ReferenceGraph,
|
||||||
|
PersistentReferenceGraph,
|
||||||
|
DependencyNode,
|
||||||
|
)
|
||||||
|
from .cache_invalidation import (
|
||||||
|
RenderCache,
|
||||||
|
CacheEntry,
|
||||||
|
CacheInvalidator,
|
||||||
|
create_invalidation_handler,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Persistent context
|
||||||
|
"SpaceTransclusionContext",
|
||||||
|
"ScopedVariables",
|
||||||
|
"VariableScope",
|
||||||
|
"CrossSpaceResolver",
|
||||||
|
# Reference graph
|
||||||
|
"ReferenceGraph",
|
||||||
|
"PersistentReferenceGraph",
|
||||||
|
"DependencyNode",
|
||||||
|
# Cache invalidation
|
||||||
|
"RenderCache",
|
||||||
|
"CacheEntry",
|
||||||
|
"CacheInvalidator",
|
||||||
|
"create_invalidation_handler",
|
||||||
|
]
|
||||||
|
|||||||
397
markitect/spaces/transclusion/cache_invalidation.py
Normal file
397
markitect/spaces/transclusion/cache_invalidation.py
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
"""
|
||||||
|
Cache invalidation for Information Spaces.
|
||||||
|
|
||||||
|
This module provides event-driven cache invalidation that uses
|
||||||
|
the reference graph to determine which documents need to be
|
||||||
|
re-rendered when content changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Set, List, Dict, Any, Optional, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ..events import (
|
||||||
|
EventBus,
|
||||||
|
SpaceEvent,
|
||||||
|
SpaceEventType,
|
||||||
|
cache_invalidated_event,
|
||||||
|
)
|
||||||
|
from .reference_graph import ReferenceGraph, PersistentReferenceGraph
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CacheEntry:
|
||||||
|
"""
|
||||||
|
Represents a cached rendering result.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
document_id: The document ID
|
||||||
|
space_id: The space ID
|
||||||
|
content_hash: Hash of source content when rendered
|
||||||
|
rendered_content: The rendered output
|
||||||
|
dependencies: Document IDs this rendering depends on
|
||||||
|
"""
|
||||||
|
|
||||||
|
document_id: str
|
||||||
|
space_id: str
|
||||||
|
content_hash: str
|
||||||
|
rendered_content: Any
|
||||||
|
dependencies: Set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
|
||||||
|
class RenderCache:
|
||||||
|
"""
|
||||||
|
Cache for rendered document content.
|
||||||
|
|
||||||
|
Stores rendered output keyed by document ID, with automatic
|
||||||
|
invalidation based on content changes and dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize an empty cache."""
|
||||||
|
self._cache: Dict[str, CacheEntry] = {}
|
||||||
|
self._by_space: Dict[str, Set[str]] = {}
|
||||||
|
|
||||||
|
def get(self, document_id: str) -> Optional[CacheEntry]:
|
||||||
|
"""
|
||||||
|
Get a cache entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: The document ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CacheEntry if found, None otherwise
|
||||||
|
"""
|
||||||
|
return self._cache.get(document_id)
|
||||||
|
|
||||||
|
def put(
|
||||||
|
self,
|
||||||
|
document_id: str,
|
||||||
|
space_id: str,
|
||||||
|
content_hash: str,
|
||||||
|
rendered_content: Any,
|
||||||
|
dependencies: Optional[Set[str]] = None,
|
||||||
|
) -> CacheEntry:
|
||||||
|
"""
|
||||||
|
Store a cache entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: The document ID
|
||||||
|
space_id: The space ID
|
||||||
|
content_hash: Source content hash
|
||||||
|
rendered_content: Rendered output
|
||||||
|
dependencies: Document IDs this depends on
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created CacheEntry
|
||||||
|
"""
|
||||||
|
entry = CacheEntry(
|
||||||
|
document_id=document_id,
|
||||||
|
space_id=space_id,
|
||||||
|
content_hash=content_hash,
|
||||||
|
rendered_content=rendered_content,
|
||||||
|
dependencies=dependencies or set(),
|
||||||
|
)
|
||||||
|
self._cache[document_id] = entry
|
||||||
|
|
||||||
|
if space_id not in self._by_space:
|
||||||
|
self._by_space[space_id] = set()
|
||||||
|
self._by_space[space_id].add(document_id)
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def invalidate(self, document_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Invalidate a single cache entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: The document ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if entry was invalidated, False if not found
|
||||||
|
"""
|
||||||
|
if document_id not in self._cache:
|
||||||
|
return False
|
||||||
|
|
||||||
|
entry = self._cache.pop(document_id)
|
||||||
|
if entry.space_id in self._by_space:
|
||||||
|
self._by_space[entry.space_id].discard(document_id)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def invalidate_many(self, document_ids: Set[str]) -> int:
|
||||||
|
"""
|
||||||
|
Invalidate multiple cache entries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_ids: Set of document IDs to invalidate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of entries invalidated
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
for doc_id in document_ids:
|
||||||
|
if self.invalidate(doc_id):
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
def invalidate_space(self, space_id: str) -> int:
|
||||||
|
"""
|
||||||
|
Invalidate all entries for a space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of entries invalidated
|
||||||
|
"""
|
||||||
|
doc_ids = self._by_space.pop(space_id, set())
|
||||||
|
count = 0
|
||||||
|
for doc_id in doc_ids:
|
||||||
|
if doc_id in self._cache:
|
||||||
|
del self._cache[doc_id]
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
def is_valid(self, document_id: str, content_hash: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a cache entry is still valid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: The document ID
|
||||||
|
content_hash: Current content hash
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if cache entry exists and matches hash
|
||||||
|
"""
|
||||||
|
entry = self._cache.get(document_id)
|
||||||
|
return entry is not None and entry.content_hash == content_hash
|
||||||
|
|
||||||
|
def get_cached_documents(self, space_id: str) -> Set[str]:
|
||||||
|
"""
|
||||||
|
Get all cached document IDs for a space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of cached document IDs
|
||||||
|
"""
|
||||||
|
return self._by_space.get(space_id, set()).copy()
|
||||||
|
|
||||||
|
def clear(self) -> int:
|
||||||
|
"""
|
||||||
|
Clear all cache entries.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of entries cleared
|
||||||
|
"""
|
||||||
|
count = len(self._cache)
|
||||||
|
self._cache.clear()
|
||||||
|
self._by_space.clear()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
class CacheInvalidator:
|
||||||
|
"""
|
||||||
|
Event-driven cache invalidation coordinator.
|
||||||
|
|
||||||
|
Listens to document change events and uses the reference graph
|
||||||
|
to invalidate affected cache entries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cache: RenderCache,
|
||||||
|
reference_graph: ReferenceGraph,
|
||||||
|
event_bus: Optional[EventBus] = None,
|
||||||
|
transitive: bool = True,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the cache invalidator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache: The render cache to invalidate
|
||||||
|
reference_graph: Reference graph for dependency tracking
|
||||||
|
event_bus: Event bus for subscribing to changes
|
||||||
|
transitive: Whether to invalidate transitive dependents
|
||||||
|
"""
|
||||||
|
self._cache = cache
|
||||||
|
self._reference_graph = reference_graph
|
||||||
|
self._event_bus = event_bus
|
||||||
|
self._transitive = transitive
|
||||||
|
self._handler_ids: List[str] = []
|
||||||
|
|
||||||
|
if event_bus:
|
||||||
|
self._subscribe_to_events()
|
||||||
|
|
||||||
|
def _subscribe_to_events(self) -> None:
|
||||||
|
"""Subscribe to relevant events."""
|
||||||
|
if not self._event_bus:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Document content changes trigger invalidation
|
||||||
|
handler_id = self._event_bus.subscribe(
|
||||||
|
SpaceEventType.DOCUMENT_CONTENT_CHANGED,
|
||||||
|
self._on_content_changed,
|
||||||
|
)
|
||||||
|
self._handler_ids.append(handler_id)
|
||||||
|
|
||||||
|
# Document removal invalidates
|
||||||
|
handler_id = self._event_bus.subscribe(
|
||||||
|
SpaceEventType.DOCUMENT_REMOVED,
|
||||||
|
self._on_document_removed,
|
||||||
|
)
|
||||||
|
self._handler_ids.append(handler_id)
|
||||||
|
|
||||||
|
# Space deletion invalidates all
|
||||||
|
handler_id = self._event_bus.subscribe(
|
||||||
|
SpaceEventType.SPACE_DELETED,
|
||||||
|
self._on_space_deleted,
|
||||||
|
)
|
||||||
|
self._handler_ids.append(handler_id)
|
||||||
|
|
||||||
|
def unsubscribe(self) -> None:
|
||||||
|
"""Unsubscribe from all events."""
|
||||||
|
if not self._event_bus:
|
||||||
|
return
|
||||||
|
|
||||||
|
for handler_id in self._handler_ids:
|
||||||
|
self._event_bus.unsubscribe_by_id(handler_id)
|
||||||
|
self._handler_ids.clear()
|
||||||
|
|
||||||
|
def _on_content_changed(self, event: SpaceEvent) -> None:
|
||||||
|
"""Handle document content change event."""
|
||||||
|
document_id = event.payload.get("document_id")
|
||||||
|
if not document_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
invalidated = self.invalidate_for_document(document_id, event.space_id)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Content changed for {document_id}, invalidated {len(invalidated)} documents"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_document_removed(self, event: SpaceEvent) -> None:
|
||||||
|
"""Handle document removal event."""
|
||||||
|
document_id = event.payload.get("document_id")
|
||||||
|
if not document_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Invalidate this document and dependents
|
||||||
|
invalidated = self.invalidate_for_document(document_id, event.space_id)
|
||||||
|
|
||||||
|
# Remove from reference graph
|
||||||
|
self._reference_graph.remove_document(document_id)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Document {document_id} removed, invalidated {len(invalidated)} documents"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_space_deleted(self, event: SpaceEvent) -> None:
|
||||||
|
"""Handle space deletion event."""
|
||||||
|
space_id = event.space_id
|
||||||
|
|
||||||
|
# Invalidate all cached documents in space
|
||||||
|
count = self._cache.invalidate_space(space_id)
|
||||||
|
|
||||||
|
# Clear reference graph
|
||||||
|
self._reference_graph.clear_space(space_id)
|
||||||
|
|
||||||
|
logger.debug(f"Space {space_id} deleted, invalidated {count} documents")
|
||||||
|
|
||||||
|
def invalidate_for_document(
|
||||||
|
self,
|
||||||
|
document_id: str,
|
||||||
|
space_id: str,
|
||||||
|
) -> Set[str]:
|
||||||
|
"""
|
||||||
|
Invalidate cache for a document and its dependents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: The changed document
|
||||||
|
space_id: The space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of invalidated document IDs
|
||||||
|
"""
|
||||||
|
to_invalidate = {document_id}
|
||||||
|
|
||||||
|
# Get dependents (documents that include this one)
|
||||||
|
if self._transitive:
|
||||||
|
dependents = self._reference_graph.get_transitive_dependents(document_id)
|
||||||
|
else:
|
||||||
|
dependents = self._reference_graph.get_dependents(document_id)
|
||||||
|
|
||||||
|
to_invalidate.update(dependents)
|
||||||
|
|
||||||
|
# Invalidate cache entries
|
||||||
|
self._cache.invalidate_many(to_invalidate)
|
||||||
|
|
||||||
|
# Emit cache invalidation event if we have event bus
|
||||||
|
if self._event_bus and to_invalidate:
|
||||||
|
event = cache_invalidated_event(
|
||||||
|
space_id=space_id,
|
||||||
|
document_ids=list(to_invalidate),
|
||||||
|
reason="content_changed",
|
||||||
|
)
|
||||||
|
self._event_bus.emit(event)
|
||||||
|
|
||||||
|
return to_invalidate
|
||||||
|
|
||||||
|
def invalidate_all(self, space_id: str) -> int:
|
||||||
|
"""
|
||||||
|
Invalidate all cache entries for a space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of entries invalidated
|
||||||
|
"""
|
||||||
|
cached_docs = self._cache.get_cached_documents(space_id)
|
||||||
|
count = self._cache.invalidate_space(space_id)
|
||||||
|
|
||||||
|
if self._event_bus and count > 0:
|
||||||
|
event = cache_invalidated_event(
|
||||||
|
space_id=space_id,
|
||||||
|
document_ids=list(cached_docs),
|
||||||
|
reason="full_invalidation",
|
||||||
|
)
|
||||||
|
self._event_bus.emit(event)
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def create_invalidation_handler(
|
||||||
|
cache: RenderCache,
|
||||||
|
reference_graph: ReferenceGraph,
|
||||||
|
) -> Callable[[SpaceEvent], None]:
|
||||||
|
"""
|
||||||
|
Create a standalone event handler for cache invalidation.
|
||||||
|
|
||||||
|
Useful when you want to add invalidation without the full CacheInvalidator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache: The render cache
|
||||||
|
reference_graph: The reference graph
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Event handler function
|
||||||
|
"""
|
||||||
|
def handler(event: SpaceEvent) -> None:
|
||||||
|
document_id = event.payload.get("document_id")
|
||||||
|
if not document_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get dependents and invalidate
|
||||||
|
to_invalidate = {document_id}
|
||||||
|
to_invalidate.update(
|
||||||
|
reference_graph.get_transitive_dependents(document_id)
|
||||||
|
)
|
||||||
|
cache.invalidate_many(to_invalidate)
|
||||||
|
|
||||||
|
return handler
|
||||||
369
markitect/spaces/transclusion/persistent_context.py
Normal file
369
markitect/spaces/transclusion/persistent_context.py
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
336
markitect/spaces/transclusion/reference_graph.py
Normal file
336
markitect/spaces/transclusion/reference_graph.py
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
"""
|
||||||
|
Reference graph for transclusion dependency tracking.
|
||||||
|
|
||||||
|
This module provides a graph-based system for tracking which documents
|
||||||
|
reference which other documents, enabling efficient cache invalidation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Dict, List, Set, Optional
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from ..models import TransclusionReference
|
||||||
|
from ..repositories.interfaces import IReferenceRepository
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DependencyNode:
|
||||||
|
"""
|
||||||
|
A node in the dependency graph representing a document.
|
||||||
|
|
||||||
|
Tracks both outgoing references (what this document includes)
|
||||||
|
and incoming references (what includes this document).
|
||||||
|
"""
|
||||||
|
|
||||||
|
document_id: str
|
||||||
|
space_id: str
|
||||||
|
# Documents this document references (includes)
|
||||||
|
references: Set[str] = field(default_factory=set)
|
||||||
|
# Documents that reference (include) this document
|
||||||
|
dependents: Set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceGraph:
|
||||||
|
"""
|
||||||
|
In-memory graph of document dependencies.
|
||||||
|
|
||||||
|
Used for efficient cache invalidation by tracking which documents
|
||||||
|
depend on which other documents.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize an empty reference graph."""
|
||||||
|
# Map of document_id -> DependencyNode
|
||||||
|
self._nodes: Dict[str, DependencyNode] = {}
|
||||||
|
# Map of space_id -> set of document_ids in that space
|
||||||
|
self._spaces: Dict[str, Set[str]] = defaultdict(set)
|
||||||
|
|
||||||
|
def _get_or_create_node(self, document_id: str, space_id: str) -> DependencyNode:
|
||||||
|
"""Get or create a node for a document."""
|
||||||
|
if document_id not in self._nodes:
|
||||||
|
self._nodes[document_id] = DependencyNode(
|
||||||
|
document_id=document_id,
|
||||||
|
space_id=space_id,
|
||||||
|
)
|
||||||
|
self._spaces[space_id].add(document_id)
|
||||||
|
return self._nodes[document_id]
|
||||||
|
|
||||||
|
def add_reference(
|
||||||
|
self,
|
||||||
|
source_doc_id: str,
|
||||||
|
target_doc_id: str,
|
||||||
|
space_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Add a reference from source to target document.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_doc_id: The document doing the including
|
||||||
|
target_doc_id: The document being included
|
||||||
|
space_id: The space ID
|
||||||
|
"""
|
||||||
|
source_node = self._get_or_create_node(source_doc_id, space_id)
|
||||||
|
target_node = self._get_or_create_node(target_doc_id, space_id)
|
||||||
|
|
||||||
|
source_node.references.add(target_doc_id)
|
||||||
|
target_node.dependents.add(source_doc_id)
|
||||||
|
|
||||||
|
def remove_reference(
|
||||||
|
self,
|
||||||
|
source_doc_id: str,
|
||||||
|
target_doc_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Remove a reference from source to target.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_doc_id: The source document
|
||||||
|
target_doc_id: The target document
|
||||||
|
"""
|
||||||
|
if source_doc_id in self._nodes:
|
||||||
|
self._nodes[source_doc_id].references.discard(target_doc_id)
|
||||||
|
if target_doc_id in self._nodes:
|
||||||
|
self._nodes[target_doc_id].dependents.discard(source_doc_id)
|
||||||
|
|
||||||
|
def clear_references_from(self, source_doc_id: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Clear all references from a source document.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_doc_id: The source document
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of target document IDs that were referenced
|
||||||
|
"""
|
||||||
|
if source_doc_id not in self._nodes:
|
||||||
|
return []
|
||||||
|
|
||||||
|
node = self._nodes[source_doc_id]
|
||||||
|
targets = list(node.references)
|
||||||
|
|
||||||
|
# Remove from all targets' dependent lists
|
||||||
|
for target_id in targets:
|
||||||
|
if target_id in self._nodes:
|
||||||
|
self._nodes[target_id].dependents.discard(source_doc_id)
|
||||||
|
|
||||||
|
node.references.clear()
|
||||||
|
return targets
|
||||||
|
|
||||||
|
def get_references(self, document_id: str) -> Set[str]:
|
||||||
|
"""
|
||||||
|
Get all documents referenced by a document.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: The document ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of referenced document IDs
|
||||||
|
"""
|
||||||
|
if document_id not in self._nodes:
|
||||||
|
return set()
|
||||||
|
return self._nodes[document_id].references.copy()
|
||||||
|
|
||||||
|
def get_dependents(self, document_id: str) -> Set[str]:
|
||||||
|
"""
|
||||||
|
Get all documents that depend on (reference) a document.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: The document ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of dependent document IDs
|
||||||
|
"""
|
||||||
|
if document_id not in self._nodes:
|
||||||
|
return set()
|
||||||
|
return self._nodes[document_id].dependents.copy()
|
||||||
|
|
||||||
|
def get_transitive_dependents(self, document_id: str) -> Set[str]:
|
||||||
|
"""
|
||||||
|
Get all documents that directly or indirectly depend on a document.
|
||||||
|
|
||||||
|
Performs a breadth-first traversal of the dependency graph.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: The document ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of all transitive dependent document IDs
|
||||||
|
"""
|
||||||
|
result = set()
|
||||||
|
to_visit = list(self.get_dependents(document_id))
|
||||||
|
visited = {document_id}
|
||||||
|
|
||||||
|
while to_visit:
|
||||||
|
current = to_visit.pop(0)
|
||||||
|
if current in visited:
|
||||||
|
continue
|
||||||
|
visited.add(current)
|
||||||
|
result.add(current)
|
||||||
|
|
||||||
|
# Add this document's dependents to visit list
|
||||||
|
for dependent in self.get_dependents(current):
|
||||||
|
if dependent not in visited:
|
||||||
|
to_visit.append(dependent)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_documents_in_space(self, space_id: str) -> Set[str]:
|
||||||
|
"""
|
||||||
|
Get all document IDs tracked in a space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of document IDs
|
||||||
|
"""
|
||||||
|
return self._spaces.get(space_id, set()).copy()
|
||||||
|
|
||||||
|
def remove_document(self, document_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Remove a document and all its references from the graph.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: The document ID to remove
|
||||||
|
"""
|
||||||
|
if document_id not in self._nodes:
|
||||||
|
return
|
||||||
|
|
||||||
|
node = self._nodes[document_id]
|
||||||
|
|
||||||
|
# Remove this document from all its targets' dependent lists
|
||||||
|
for target_id in node.references:
|
||||||
|
if target_id in self._nodes:
|
||||||
|
self._nodes[target_id].dependents.discard(document_id)
|
||||||
|
|
||||||
|
# Remove this document from all its dependents' reference lists
|
||||||
|
for dependent_id in node.dependents:
|
||||||
|
if dependent_id in self._nodes:
|
||||||
|
self._nodes[dependent_id].references.discard(document_id)
|
||||||
|
|
||||||
|
# Remove from space tracking
|
||||||
|
self._spaces[node.space_id].discard(document_id)
|
||||||
|
|
||||||
|
# Delete the node
|
||||||
|
del self._nodes[document_id]
|
||||||
|
|
||||||
|
def clear_space(self, space_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Clear all references for documents in a space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
"""
|
||||||
|
doc_ids = list(self._spaces.get(space_id, set()))
|
||||||
|
for doc_id in doc_ids:
|
||||||
|
self.remove_document(doc_id)
|
||||||
|
|
||||||
|
|
||||||
|
class PersistentReferenceGraph(ReferenceGraph):
|
||||||
|
"""
|
||||||
|
Reference graph backed by persistent storage.
|
||||||
|
|
||||||
|
Extends ReferenceGraph to persist references to a repository,
|
||||||
|
enabling cache invalidation across restarts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
reference_repo: IReferenceRepository,
|
||||||
|
load_on_init: bool = True,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize a persistent reference graph.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The space ID
|
||||||
|
reference_repo: Repository for persisting references
|
||||||
|
load_on_init: Whether to load existing references on init
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self.space_id = space_id
|
||||||
|
self._reference_repo = reference_repo
|
||||||
|
|
||||||
|
if load_on_init:
|
||||||
|
self._load_from_repository()
|
||||||
|
|
||||||
|
def _load_from_repository(self) -> None:
|
||||||
|
"""Load all references from the repository."""
|
||||||
|
# Get all documents in space and their references
|
||||||
|
# This is a simplified approach - in production you might want
|
||||||
|
# to load lazily or use a more efficient query
|
||||||
|
pass # Repository doesn't have a list_all method, would need to enhance
|
||||||
|
|
||||||
|
def add_reference(
|
||||||
|
self,
|
||||||
|
source_doc_id: str,
|
||||||
|
target_doc_id: str,
|
||||||
|
space_id: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Add a reference and persist it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_doc_id: Source document ID
|
||||||
|
target_doc_id: Target document ID
|
||||||
|
space_id: Optional space ID override
|
||||||
|
"""
|
||||||
|
space = space_id or self.space_id
|
||||||
|
|
||||||
|
# Update in-memory graph
|
||||||
|
super().add_reference(source_doc_id, target_doc_id, space)
|
||||||
|
|
||||||
|
# Persist to repository
|
||||||
|
ref = TransclusionReference(
|
||||||
|
source_doc_id=source_doc_id,
|
||||||
|
target_doc_id=target_doc_id,
|
||||||
|
space_id=space,
|
||||||
|
)
|
||||||
|
self._reference_repo.add_reference(ref)
|
||||||
|
|
||||||
|
def clear_references_from(self, source_doc_id: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Clear references from source and persist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_doc_id: Source document ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of cleared target document IDs
|
||||||
|
"""
|
||||||
|
# Clear from in-memory graph
|
||||||
|
targets = super().clear_references_from(source_doc_id)
|
||||||
|
|
||||||
|
# Clear from repository
|
||||||
|
self._reference_repo.clear_references_from(source_doc_id, self.space_id)
|
||||||
|
|
||||||
|
return targets
|
||||||
|
|
||||||
|
def get_dependents_from_repo(self, document_id: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Get dependents directly from repository.
|
||||||
|
|
||||||
|
Useful when graph may not be fully loaded.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: The document ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dependent document IDs
|
||||||
|
"""
|
||||||
|
return self._reference_repo.get_dependents(document_id, self.space_id)
|
||||||
|
|
||||||
|
def sync_with_repository(self) -> None:
|
||||||
|
"""
|
||||||
|
Sync in-memory graph with repository.
|
||||||
|
|
||||||
|
Useful after batch operations or to ensure consistency.
|
||||||
|
"""
|
||||||
|
# Clear in-memory graph for this space
|
||||||
|
self.clear_space(self.space_id)
|
||||||
|
|
||||||
|
# Reload from repository
|
||||||
|
# Note: This would need a method to list all references in space
|
||||||
|
# For now, the graph is built incrementally during document processing
|
||||||
|
pass
|
||||||
565
tests/unit/spaces/test_transclusion.py
Normal file
565
tests/unit/spaces/test_transclusion.py
Normal file
@@ -0,0 +1,565 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for transclusion context and cache invalidation.
|
||||||
|
|
||||||
|
Tests the Phase 3 components:
|
||||||
|
- SpaceTransclusionContext with scoped variables
|
||||||
|
- ReferenceGraph for dependency tracking
|
||||||
|
- RenderCache and CacheInvalidator
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from markitect.spaces.transclusion import (
|
||||||
|
SpaceTransclusionContext,
|
||||||
|
ScopedVariables,
|
||||||
|
VariableScope,
|
||||||
|
CrossSpaceResolver,
|
||||||
|
ReferenceGraph,
|
||||||
|
DependencyNode,
|
||||||
|
RenderCache,
|
||||||
|
CacheEntry,
|
||||||
|
CacheInvalidator,
|
||||||
|
)
|
||||||
|
from markitect.spaces.events import EventBus, SpaceEventType
|
||||||
|
|
||||||
|
|
||||||
|
class TestScopedVariables:
|
||||||
|
"""Tests for ScopedVariables."""
|
||||||
|
|
||||||
|
def test_empty_variables(self):
|
||||||
|
"""Test empty scoped variables."""
|
||||||
|
scoped = ScopedVariables()
|
||||||
|
assert scoped.get("foo") is None
|
||||||
|
assert scoped.get("foo", "default") == "default"
|
||||||
|
|
||||||
|
def test_set_and_get_request_scope(self):
|
||||||
|
"""Test request scope variables."""
|
||||||
|
scoped = ScopedVariables()
|
||||||
|
scoped.set("key", "request_value", VariableScope.REQUEST)
|
||||||
|
assert scoped.get("key") == "request_value"
|
||||||
|
|
||||||
|
def test_set_and_get_document_scope(self):
|
||||||
|
"""Test document scope variables."""
|
||||||
|
scoped = ScopedVariables()
|
||||||
|
scoped.set("key", "doc_value", VariableScope.DOCUMENT)
|
||||||
|
assert scoped.get("key") == "doc_value"
|
||||||
|
|
||||||
|
def test_set_and_get_space_scope(self):
|
||||||
|
"""Test space scope variables."""
|
||||||
|
scoped = ScopedVariables()
|
||||||
|
scoped.set("key", "space_value", VariableScope.SPACE)
|
||||||
|
assert scoped.get("key") == "space_value"
|
||||||
|
|
||||||
|
def test_scope_precedence(self):
|
||||||
|
"""Test that higher scopes override lower scopes."""
|
||||||
|
scoped = ScopedVariables()
|
||||||
|
scoped.set("key", "space_value", VariableScope.SPACE)
|
||||||
|
scoped.set("key", "doc_value", VariableScope.DOCUMENT)
|
||||||
|
scoped.set("key", "request_value", VariableScope.REQUEST)
|
||||||
|
|
||||||
|
# Request scope wins
|
||||||
|
assert scoped.get("key") == "request_value"
|
||||||
|
|
||||||
|
def test_scope_fallback(self):
|
||||||
|
"""Test fallback to lower scopes."""
|
||||||
|
scoped = ScopedVariables()
|
||||||
|
scoped.set("space_only", "from_space", VariableScope.SPACE)
|
||||||
|
scoped.set("doc_only", "from_doc", VariableScope.DOCUMENT)
|
||||||
|
|
||||||
|
assert scoped.get("space_only") == "from_space"
|
||||||
|
assert scoped.get("doc_only") == "from_doc"
|
||||||
|
|
||||||
|
def test_get_all(self):
|
||||||
|
"""Test getting all merged variables."""
|
||||||
|
scoped = ScopedVariables()
|
||||||
|
scoped.set("a", "space_a", VariableScope.SPACE)
|
||||||
|
scoped.set("b", "doc_b", VariableScope.DOCUMENT)
|
||||||
|
scoped.set("c", "req_c", VariableScope.REQUEST)
|
||||||
|
scoped.set("a", "req_a", VariableScope.REQUEST) # Override
|
||||||
|
|
||||||
|
all_vars = scoped.get_all()
|
||||||
|
assert all_vars["a"] == "req_a" # Request wins
|
||||||
|
assert all_vars["b"] == "doc_b"
|
||||||
|
assert all_vars["c"] == "req_c"
|
||||||
|
|
||||||
|
def test_clear_scope(self):
|
||||||
|
"""Test clearing a specific scope."""
|
||||||
|
scoped = ScopedVariables()
|
||||||
|
scoped.set("key", "space_value", VariableScope.SPACE)
|
||||||
|
scoped.set("key", "request_value", VariableScope.REQUEST)
|
||||||
|
|
||||||
|
scoped.clear_scope(VariableScope.REQUEST)
|
||||||
|
|
||||||
|
# Now should fall back to space
|
||||||
|
assert scoped.get("key") == "space_value"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpaceTransclusionContext:
|
||||||
|
"""Tests for SpaceTransclusionContext."""
|
||||||
|
|
||||||
|
def test_basic_creation(self):
|
||||||
|
"""Test basic context creation."""
|
||||||
|
ctx = SpaceTransclusionContext(
|
||||||
|
space_id="space-1",
|
||||||
|
base_path=Path("/test"),
|
||||||
|
)
|
||||||
|
assert ctx.space_id == "space-1"
|
||||||
|
assert ctx.base_path == Path("/test")
|
||||||
|
|
||||||
|
def test_initial_variables(self):
|
||||||
|
"""Test context with initial variables."""
|
||||||
|
ctx = SpaceTransclusionContext(
|
||||||
|
space_id="space-1",
|
||||||
|
variables={"version": "1.0", "api_url": "https://api.example.com"},
|
||||||
|
)
|
||||||
|
assert ctx.get_variable("version") == "1.0"
|
||||||
|
assert ctx.get_variable("api_url") == "https://api.example.com"
|
||||||
|
|
||||||
|
def test_set_variable_with_scope(self):
|
||||||
|
"""Test setting variables with different scopes."""
|
||||||
|
ctx = SpaceTransclusionContext(space_id="space-1")
|
||||||
|
|
||||||
|
ctx.set_variable("global", "g", VariableScope.SPACE)
|
||||||
|
ctx.set_variable("local", "l", VariableScope.REQUEST)
|
||||||
|
|
||||||
|
assert ctx.get_variable("global") == "g"
|
||||||
|
assert ctx.get_variable("local") == "l"
|
||||||
|
|
||||||
|
def test_variable_substitution(self):
|
||||||
|
"""Test variable substitution in text."""
|
||||||
|
ctx = SpaceTransclusionContext(
|
||||||
|
space_id="space-1",
|
||||||
|
variables={"name": "John", "version": "2.0"},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = ctx.substitute_variables("Hello {{name}}, welcome to v{{version}}")
|
||||||
|
assert result == "Hello John, welcome to v2.0"
|
||||||
|
|
||||||
|
def test_variable_substitution_missing(self):
|
||||||
|
"""Test that missing variables are left unchanged."""
|
||||||
|
ctx = SpaceTransclusionContext(space_id="space-1")
|
||||||
|
|
||||||
|
result = ctx.substitute_variables("Hello {{missing}}")
|
||||||
|
assert result == "Hello {{missing}}"
|
||||||
|
|
||||||
|
def test_reference_tracking(self):
|
||||||
|
"""Test reference tracking during processing."""
|
||||||
|
ctx = SpaceTransclusionContext(space_id="space-1")
|
||||||
|
|
||||||
|
ctx.set_current_document("doc-1")
|
||||||
|
ctx.track_reference("component-a")
|
||||||
|
ctx.track_reference("component-b")
|
||||||
|
|
||||||
|
refs = ctx.get_tracked_references()
|
||||||
|
assert len(refs) == 2
|
||||||
|
assert ("doc-1", "component-a") in refs
|
||||||
|
assert ("doc-1", "component-b") in refs
|
||||||
|
|
||||||
|
def test_clear_tracked_references(self):
|
||||||
|
"""Test clearing tracked references."""
|
||||||
|
ctx = SpaceTransclusionContext(space_id="space-1")
|
||||||
|
ctx.set_current_document("doc-1")
|
||||||
|
ctx.track_reference("target")
|
||||||
|
|
||||||
|
ctx.clear_tracked_references()
|
||||||
|
|
||||||
|
assert len(ctx.get_tracked_references()) == 0
|
||||||
|
|
||||||
|
def test_create_child_context(self):
|
||||||
|
"""Test creating a child context."""
|
||||||
|
parent = SpaceTransclusionContext(
|
||||||
|
space_id="space-1",
|
||||||
|
variables={"inherited": "value"},
|
||||||
|
)
|
||||||
|
parent.set_variable("space_var", "sv", VariableScope.SPACE)
|
||||||
|
|
||||||
|
child = parent.create_child_context(new_base_path=Path("/child"))
|
||||||
|
|
||||||
|
assert child.space_id == "space-1"
|
||||||
|
assert child.base_path == Path("/child")
|
||||||
|
assert child.get_variable("inherited") == "value"
|
||||||
|
assert child.get_variable("space_var") == "sv"
|
||||||
|
|
||||||
|
def test_child_context_shares_references(self):
|
||||||
|
"""Test that child context shares reference tracking."""
|
||||||
|
parent = SpaceTransclusionContext(space_id="space-1")
|
||||||
|
parent.set_current_document("doc-1")
|
||||||
|
|
||||||
|
child = parent.create_child_context()
|
||||||
|
child.track_reference("from-child")
|
||||||
|
|
||||||
|
# Parent should see the reference
|
||||||
|
refs = parent.get_tracked_references()
|
||||||
|
assert ("doc-1", "from-child") in refs
|
||||||
|
|
||||||
|
def test_variables_property(self):
|
||||||
|
"""Test the variables property returns merged dict."""
|
||||||
|
ctx = SpaceTransclusionContext(space_id="space-1")
|
||||||
|
ctx.set_variable("a", "1", VariableScope.SPACE)
|
||||||
|
ctx.set_variable("b", "2", VariableScope.REQUEST)
|
||||||
|
|
||||||
|
vars_dict = ctx.variables
|
||||||
|
assert vars_dict["a"] == "1"
|
||||||
|
assert vars_dict["b"] == "2"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrossSpaceResolver:
|
||||||
|
"""Tests for CrossSpaceResolver."""
|
||||||
|
|
||||||
|
def test_add_and_get_context(self):
|
||||||
|
"""Test adding and getting contexts."""
|
||||||
|
resolver = CrossSpaceResolver({})
|
||||||
|
|
||||||
|
ctx1 = SpaceTransclusionContext(space_id="space-1")
|
||||||
|
resolver.add_context("space-1", ctx1)
|
||||||
|
|
||||||
|
assert resolver.get_context("space-1") is ctx1
|
||||||
|
assert resolver.get_context("space-2") is None
|
||||||
|
|
||||||
|
def test_resolve_variable_from_space(self):
|
||||||
|
"""Test resolving variables across spaces."""
|
||||||
|
ctx1 = SpaceTransclusionContext(
|
||||||
|
space_id="space-1",
|
||||||
|
variables={"api_key": "key-1"},
|
||||||
|
)
|
||||||
|
ctx2 = SpaceTransclusionContext(
|
||||||
|
space_id="space-2",
|
||||||
|
variables={"api_key": "key-2"},
|
||||||
|
)
|
||||||
|
|
||||||
|
resolver = CrossSpaceResolver({"space-1": ctx1, "space-2": ctx2})
|
||||||
|
|
||||||
|
assert resolver.resolve_variable("space-1", "api_key") == "key-1"
|
||||||
|
assert resolver.resolve_variable("space-2", "api_key") == "key-2"
|
||||||
|
assert resolver.resolve_variable("space-3", "api_key") is None
|
||||||
|
|
||||||
|
def test_resolve_cross_space_reference(self):
|
||||||
|
"""Test parsing cross-space references."""
|
||||||
|
resolver = CrossSpaceResolver({})
|
||||||
|
|
||||||
|
# Cross-space reference
|
||||||
|
result = resolver.resolve_cross_space_reference(
|
||||||
|
"space:other-space/docs/intro.md", "current-space"
|
||||||
|
)
|
||||||
|
assert result == ("other-space", "/docs/intro.md")
|
||||||
|
|
||||||
|
# Same-space reference
|
||||||
|
result = resolver.resolve_cross_space_reference(
|
||||||
|
"/docs/intro.md", "current-space"
|
||||||
|
)
|
||||||
|
assert result == ("current-space", "/docs/intro.md")
|
||||||
|
|
||||||
|
|
||||||
|
class TestReferenceGraph:
|
||||||
|
"""Tests for ReferenceGraph."""
|
||||||
|
|
||||||
|
def test_empty_graph(self):
|
||||||
|
"""Test empty reference graph."""
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
assert graph.get_references("doc-1") == set()
|
||||||
|
assert graph.get_dependents("doc-1") == set()
|
||||||
|
|
||||||
|
def test_add_reference(self):
|
||||||
|
"""Test adding a reference."""
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
graph.add_reference("doc-1", "component-a", "space-1")
|
||||||
|
|
||||||
|
assert "component-a" in graph.get_references("doc-1")
|
||||||
|
assert "doc-1" in graph.get_dependents("component-a")
|
||||||
|
|
||||||
|
def test_multiple_references(self):
|
||||||
|
"""Test multiple references from one document."""
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
graph.add_reference("doc-1", "comp-a", "space-1")
|
||||||
|
graph.add_reference("doc-1", "comp-b", "space-1")
|
||||||
|
|
||||||
|
refs = graph.get_references("doc-1")
|
||||||
|
assert refs == {"comp-a", "comp-b"}
|
||||||
|
|
||||||
|
def test_multiple_dependents(self):
|
||||||
|
"""Test multiple documents depending on one."""
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
graph.add_reference("doc-1", "shared", "space-1")
|
||||||
|
graph.add_reference("doc-2", "shared", "space-1")
|
||||||
|
graph.add_reference("doc-3", "shared", "space-1")
|
||||||
|
|
||||||
|
deps = graph.get_dependents("shared")
|
||||||
|
assert deps == {"doc-1", "doc-2", "doc-3"}
|
||||||
|
|
||||||
|
def test_remove_reference(self):
|
||||||
|
"""Test removing a reference."""
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
graph.add_reference("doc-1", "target", "space-1")
|
||||||
|
graph.remove_reference("doc-1", "target")
|
||||||
|
|
||||||
|
assert graph.get_references("doc-1") == set()
|
||||||
|
assert graph.get_dependents("target") == set()
|
||||||
|
|
||||||
|
def test_clear_references_from(self):
|
||||||
|
"""Test clearing all references from a document."""
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
graph.add_reference("doc-1", "a", "space-1")
|
||||||
|
graph.add_reference("doc-1", "b", "space-1")
|
||||||
|
graph.add_reference("doc-1", "c", "space-1")
|
||||||
|
|
||||||
|
targets = graph.clear_references_from("doc-1")
|
||||||
|
|
||||||
|
assert set(targets) == {"a", "b", "c"}
|
||||||
|
assert graph.get_references("doc-1") == set()
|
||||||
|
|
||||||
|
def test_transitive_dependents(self):
|
||||||
|
"""Test getting transitive dependents."""
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
# doc-1 -> shared
|
||||||
|
# doc-2 -> doc-1
|
||||||
|
# doc-3 -> doc-2
|
||||||
|
graph.add_reference("doc-1", "shared", "space-1")
|
||||||
|
graph.add_reference("doc-2", "doc-1", "space-1")
|
||||||
|
graph.add_reference("doc-3", "doc-2", "space-1")
|
||||||
|
|
||||||
|
# Transitive dependents of shared
|
||||||
|
deps = graph.get_transitive_dependents("shared")
|
||||||
|
assert deps == {"doc-1", "doc-2", "doc-3"}
|
||||||
|
|
||||||
|
def test_transitive_dependents_with_cycle(self):
|
||||||
|
"""Test transitive dependents handles cycles gracefully."""
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
# Create a cycle: a -> b -> c -> a
|
||||||
|
graph.add_reference("a", "b", "space-1")
|
||||||
|
graph.add_reference("b", "c", "space-1")
|
||||||
|
graph.add_reference("c", "a", "space-1")
|
||||||
|
|
||||||
|
# Should not infinite loop
|
||||||
|
deps = graph.get_transitive_dependents("b")
|
||||||
|
assert "a" in deps
|
||||||
|
|
||||||
|
def test_get_documents_in_space(self):
|
||||||
|
"""Test getting all documents in a space."""
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
graph.add_reference("doc-1", "doc-2", "space-1")
|
||||||
|
graph.add_reference("doc-3", "doc-4", "space-1")
|
||||||
|
graph.add_reference("other", "doc", "space-2")
|
||||||
|
|
||||||
|
space1_docs = graph.get_documents_in_space("space-1")
|
||||||
|
assert space1_docs == {"doc-1", "doc-2", "doc-3", "doc-4"}
|
||||||
|
|
||||||
|
def test_remove_document(self):
|
||||||
|
"""Test removing a document from the graph."""
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
graph.add_reference("doc-1", "shared", "space-1")
|
||||||
|
graph.add_reference("doc-2", "shared", "space-1")
|
||||||
|
|
||||||
|
graph.remove_document("doc-1")
|
||||||
|
|
||||||
|
# doc-1's references should be gone
|
||||||
|
assert "doc-1" not in graph.get_dependents("shared")
|
||||||
|
# doc-2's references should remain
|
||||||
|
assert "doc-2" in graph.get_dependents("shared")
|
||||||
|
|
||||||
|
def test_clear_space(self):
|
||||||
|
"""Test clearing all documents in a space."""
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
graph.add_reference("doc-1", "doc-2", "space-1")
|
||||||
|
graph.add_reference("doc-3", "doc-4", "space-1")
|
||||||
|
graph.add_reference("other", "doc", "space-2")
|
||||||
|
|
||||||
|
graph.clear_space("space-1")
|
||||||
|
|
||||||
|
assert graph.get_documents_in_space("space-1") == set()
|
||||||
|
assert graph.get_documents_in_space("space-2") == {"other", "doc"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderCache:
|
||||||
|
"""Tests for RenderCache."""
|
||||||
|
|
||||||
|
def test_empty_cache(self):
|
||||||
|
"""Test empty cache."""
|
||||||
|
cache = RenderCache()
|
||||||
|
assert cache.get("doc-1") is None
|
||||||
|
|
||||||
|
def test_put_and_get(self):
|
||||||
|
"""Test putting and getting cache entries."""
|
||||||
|
cache = RenderCache()
|
||||||
|
entry = cache.put("doc-1", "space-1", "hash123", "<html>content</html>")
|
||||||
|
|
||||||
|
retrieved = cache.get("doc-1")
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.rendered_content == "<html>content</html>"
|
||||||
|
assert retrieved.content_hash == "hash123"
|
||||||
|
|
||||||
|
def test_is_valid(self):
|
||||||
|
"""Test validity checking."""
|
||||||
|
cache = RenderCache()
|
||||||
|
cache.put("doc-1", "space-1", "hash123", "content")
|
||||||
|
|
||||||
|
assert cache.is_valid("doc-1", "hash123") is True
|
||||||
|
assert cache.is_valid("doc-1", "different_hash") is False
|
||||||
|
assert cache.is_valid("non-existent", "hash123") is False
|
||||||
|
|
||||||
|
def test_invalidate(self):
|
||||||
|
"""Test invalidating a cache entry."""
|
||||||
|
cache = RenderCache()
|
||||||
|
cache.put("doc-1", "space-1", "hash", "content")
|
||||||
|
|
||||||
|
result = cache.invalidate("doc-1")
|
||||||
|
assert result is True
|
||||||
|
assert cache.get("doc-1") is None
|
||||||
|
|
||||||
|
def test_invalidate_nonexistent(self):
|
||||||
|
"""Test invalidating non-existent entry."""
|
||||||
|
cache = RenderCache()
|
||||||
|
result = cache.invalidate("non-existent")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_invalidate_many(self):
|
||||||
|
"""Test invalidating multiple entries."""
|
||||||
|
cache = RenderCache()
|
||||||
|
cache.put("doc-1", "space-1", "h1", "c1")
|
||||||
|
cache.put("doc-2", "space-1", "h2", "c2")
|
||||||
|
cache.put("doc-3", "space-1", "h3", "c3")
|
||||||
|
|
||||||
|
count = cache.invalidate_many({"doc-1", "doc-2", "doc-4"})
|
||||||
|
assert count == 2
|
||||||
|
assert cache.get("doc-1") is None
|
||||||
|
assert cache.get("doc-2") is None
|
||||||
|
assert cache.get("doc-3") is not None
|
||||||
|
|
||||||
|
def test_invalidate_space(self):
|
||||||
|
"""Test invalidating all entries in a space."""
|
||||||
|
cache = RenderCache()
|
||||||
|
cache.put("doc-1", "space-1", "h1", "c1")
|
||||||
|
cache.put("doc-2", "space-1", "h2", "c2")
|
||||||
|
cache.put("doc-3", "space-2", "h3", "c3")
|
||||||
|
|
||||||
|
count = cache.invalidate_space("space-1")
|
||||||
|
assert count == 2
|
||||||
|
assert cache.get("doc-1") is None
|
||||||
|
assert cache.get("doc-2") is None
|
||||||
|
assert cache.get("doc-3") is not None
|
||||||
|
|
||||||
|
def test_get_cached_documents(self):
|
||||||
|
"""Test getting cached document IDs for a space."""
|
||||||
|
cache = RenderCache()
|
||||||
|
cache.put("doc-1", "space-1", "h1", "c1")
|
||||||
|
cache.put("doc-2", "space-1", "h2", "c2")
|
||||||
|
cache.put("doc-3", "space-2", "h3", "c3")
|
||||||
|
|
||||||
|
docs = cache.get_cached_documents("space-1")
|
||||||
|
assert docs == {"doc-1", "doc-2"}
|
||||||
|
|
||||||
|
def test_clear(self):
|
||||||
|
"""Test clearing all cache entries."""
|
||||||
|
cache = RenderCache()
|
||||||
|
cache.put("doc-1", "space-1", "h1", "c1")
|
||||||
|
cache.put("doc-2", "space-2", "h2", "c2")
|
||||||
|
|
||||||
|
count = cache.clear()
|
||||||
|
assert count == 2
|
||||||
|
assert cache.get("doc-1") is None
|
||||||
|
assert cache.get("doc-2") is None
|
||||||
|
|
||||||
|
def test_cache_with_dependencies(self):
|
||||||
|
"""Test cache entry with dependencies."""
|
||||||
|
cache = RenderCache()
|
||||||
|
entry = cache.put(
|
||||||
|
"doc-1",
|
||||||
|
"space-1",
|
||||||
|
"hash",
|
||||||
|
"content",
|
||||||
|
dependencies={"comp-a", "comp-b"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert entry.dependencies == {"comp-a", "comp-b"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCacheInvalidator:
|
||||||
|
"""Tests for CacheInvalidator."""
|
||||||
|
|
||||||
|
def test_basic_invalidation(self):
|
||||||
|
"""Test basic document invalidation."""
|
||||||
|
cache = RenderCache()
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
|
||||||
|
cache.put("doc-1", "space-1", "h1", "c1")
|
||||||
|
|
||||||
|
invalidator = CacheInvalidator(cache, graph)
|
||||||
|
invalidated = invalidator.invalidate_for_document("doc-1", "space-1")
|
||||||
|
|
||||||
|
assert "doc-1" in invalidated
|
||||||
|
assert cache.get("doc-1") is None
|
||||||
|
|
||||||
|
def test_invalidation_with_dependents(self):
|
||||||
|
"""Test invalidation cascades to dependents."""
|
||||||
|
cache = RenderCache()
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
|
||||||
|
# doc-1 and doc-2 depend on shared
|
||||||
|
graph.add_reference("doc-1", "shared", "space-1")
|
||||||
|
graph.add_reference("doc-2", "shared", "space-1")
|
||||||
|
|
||||||
|
cache.put("doc-1", "space-1", "h1", "c1")
|
||||||
|
cache.put("doc-2", "space-1", "h2", "c2")
|
||||||
|
cache.put("shared", "space-1", "hs", "cs")
|
||||||
|
|
||||||
|
invalidator = CacheInvalidator(cache, graph, transitive=True)
|
||||||
|
invalidated = invalidator.invalidate_for_document("shared", "space-1")
|
||||||
|
|
||||||
|
assert "shared" in invalidated
|
||||||
|
assert "doc-1" in invalidated
|
||||||
|
assert "doc-2" in invalidated
|
||||||
|
|
||||||
|
def test_invalidation_non_transitive(self):
|
||||||
|
"""Test non-transitive invalidation."""
|
||||||
|
cache = RenderCache()
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
|
||||||
|
# doc-1 -> shared, doc-2 -> doc-1
|
||||||
|
graph.add_reference("doc-1", "shared", "space-1")
|
||||||
|
graph.add_reference("doc-2", "doc-1", "space-1")
|
||||||
|
|
||||||
|
cache.put("doc-1", "space-1", "h1", "c1")
|
||||||
|
cache.put("doc-2", "space-1", "h2", "c2")
|
||||||
|
|
||||||
|
invalidator = CacheInvalidator(cache, graph, transitive=False)
|
||||||
|
invalidated = invalidator.invalidate_for_document("shared", "space-1")
|
||||||
|
|
||||||
|
# Only direct dependent should be invalidated
|
||||||
|
assert "doc-1" in invalidated
|
||||||
|
assert "doc-2" not in invalidated
|
||||||
|
|
||||||
|
def test_event_subscription(self):
|
||||||
|
"""Test that invalidator subscribes to events."""
|
||||||
|
cache = RenderCache()
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
bus = EventBus()
|
||||||
|
|
||||||
|
cache.put("doc-1", "space-1", "h1", "c1")
|
||||||
|
|
||||||
|
invalidator = CacheInvalidator(cache, graph, event_bus=bus)
|
||||||
|
|
||||||
|
# Emit content changed event
|
||||||
|
from markitect.spaces.events import document_content_changed_event
|
||||||
|
|
||||||
|
bus.emit(document_content_changed_event("space-1", "doc-1", "h1", "h2"))
|
||||||
|
|
||||||
|
# Cache should be invalidated
|
||||||
|
assert cache.get("doc-1") is None
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
invalidator.unsubscribe()
|
||||||
|
|
||||||
|
def test_invalidate_all(self):
|
||||||
|
"""Test invalidating all entries in a space."""
|
||||||
|
cache = RenderCache()
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
|
||||||
|
cache.put("doc-1", "space-1", "h1", "c1")
|
||||||
|
cache.put("doc-2", "space-1", "h2", "c2")
|
||||||
|
|
||||||
|
invalidator = CacheInvalidator(cache, graph)
|
||||||
|
count = invalidator.invalidate_all("space-1")
|
||||||
|
|
||||||
|
assert count == 2
|
||||||
|
assert cache.get("doc-1") is None
|
||||||
|
assert cache.get("doc-2") is None
|
||||||
Reference in New Issue
Block a user