feat(prompts): implement Phase 1 - Foundation (FR-1)
Implement addressable artifacts with content-based identity and change detection. Core Features: - Artifact model with SHA-256 content digests - ArtifactReference for cross-space addressing - IArtifactRepository interface for pluggable storage - SQLiteArtifactRepository implementation - ArtifactService for high-level operations - Content digest calculation utilities Database: - prompt_artifacts table with indexes - Support for artifact metadata and types - UNIQUE constraint on space_id+name Tests (41 passing): - 26 model tests (metadata, artifacts, references, digests) - 15 repository tests (CRUD, queries, constraints) Implements: - FR-1.1: Unique addressability by name and ID - FR-1.2: Content digest computation and storage - FR-1.3: Cross-space artifact references Files Created: - markitect/prompts/models.py - markitect/prompts/repositories/interfaces.py - markitect/prompts/repositories/sqlite.py - markitect/prompts/services/artifact_service.py - migrations/prompts/001_create_artifacts_table.sql - tests/unit/prompts/test_artifact_models.py - tests/unit/prompts/test_artifact_repository.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
33
markitect/prompts/__init__.py
Normal file
33
markitect/prompts/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
Prompt Dependency Resolution system for MarkiTect.
|
||||||
|
|
||||||
|
This package provides infrastructure for executing PromptTemplates with
|
||||||
|
deterministic dependency resolution, incremental recomputation, and quality
|
||||||
|
validation across InformationSpaces.
|
||||||
|
|
||||||
|
Key components:
|
||||||
|
- Artifact management with content-based addressing
|
||||||
|
- Template definition with macro support
|
||||||
|
- Deterministic resolution across spaces
|
||||||
|
- Idempotent execution with InputBundleHash
|
||||||
|
- Dependency graph construction and tracking
|
||||||
|
- Incremental recomputation with change impact analysis
|
||||||
|
- Quality gate validation and halting policies
|
||||||
|
- Complete traceability and provenance tracking
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
from markitect.prompts.models import (
|
||||||
|
Artifact,
|
||||||
|
ArtifactReference,
|
||||||
|
ArtifactMetadata,
|
||||||
|
ArtifactType,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Artifact",
|
||||||
|
"ArtifactReference",
|
||||||
|
"ArtifactMetadata",
|
||||||
|
"ArtifactType",
|
||||||
|
]
|
||||||
279
markitect/prompts/models.py
Normal file
279
markitect/prompts/models.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
"""
|
||||||
|
Core domain models for Prompt Dependency Resolution.
|
||||||
|
|
||||||
|
This module provides foundational data models for addressable artifacts,
|
||||||
|
references, and content hashing for change detection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactType(Enum):
|
||||||
|
"""Type classification for artifacts."""
|
||||||
|
CONTENT = "content" # Regular content artifact
|
||||||
|
TEMPLATE = "template" # Prompt template
|
||||||
|
GENERATED = "generated" # LLM-generated artifact
|
||||||
|
SCHEMA = "schema" # Validation schema
|
||||||
|
CONFIG = "config" # Configuration artifact
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ArtifactMetadata:
|
||||||
|
"""
|
||||||
|
Extensible metadata for artifacts.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
description: Human-readable description
|
||||||
|
tags: List of tags for categorization
|
||||||
|
author: Author identifier
|
||||||
|
version: Version string (optional)
|
||||||
|
custom: Dictionary for custom metadata fields
|
||||||
|
"""
|
||||||
|
description: Optional[str] = None
|
||||||
|
tags: list[str] = field(default_factory=list)
|
||||||
|
author: Optional[str] = None
|
||||||
|
version: Optional[str] = None
|
||||||
|
custom: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert metadata to dictionary for serialization."""
|
||||||
|
return {
|
||||||
|
"description": self.description,
|
||||||
|
"tags": self.tags,
|
||||||
|
"author": self.author,
|
||||||
|
"version": self.version,
|
||||||
|
"custom": self.custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "ArtifactMetadata":
|
||||||
|
"""Create metadata from dictionary."""
|
||||||
|
return cls(
|
||||||
|
description=data.get("description"),
|
||||||
|
tags=data.get("tags", []),
|
||||||
|
author=data.get("author"),
|
||||||
|
version=data.get("version"),
|
||||||
|
custom=data.get("custom", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Artifact:
|
||||||
|
"""
|
||||||
|
Core artifact entity with content-based addressing.
|
||||||
|
|
||||||
|
Implements FR-1: InformationSpace Addressability
|
||||||
|
- Unique persistent identifier
|
||||||
|
- Content digest for change detection
|
||||||
|
- Cross-space references via space_id
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier (UUID)
|
||||||
|
space_id: ID of containing InformationSpace
|
||||||
|
name: Human-readable name (unique within space)
|
||||||
|
artifact_type: Classification of artifact
|
||||||
|
content_digest: SHA-256 hash of content
|
||||||
|
content_size: Size of content in bytes
|
||||||
|
metadata: Extensible metadata
|
||||||
|
created_at: Creation timestamp
|
||||||
|
updated_at: Last modification timestamp
|
||||||
|
"""
|
||||||
|
id: str
|
||||||
|
space_id: str
|
||||||
|
name: str
|
||||||
|
artifact_type: ArtifactType
|
||||||
|
content_digest: str
|
||||||
|
content_size: int = 0
|
||||||
|
metadata: ArtifactMetadata = field(default_factory=ArtifactMetadata)
|
||||||
|
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
space_id: str,
|
||||||
|
name: str,
|
||||||
|
content: str,
|
||||||
|
artifact_type: ArtifactType = ArtifactType.CONTENT,
|
||||||
|
metadata: Optional[ArtifactMetadata] = None,
|
||||||
|
) -> "Artifact":
|
||||||
|
"""
|
||||||
|
Create a new artifact with automatic ID and digest generation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: ID of containing space
|
||||||
|
name: Artifact name
|
||||||
|
content: Artifact content
|
||||||
|
artifact_type: Type classification
|
||||||
|
metadata: Optional metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New Artifact instance with computed digest
|
||||||
|
"""
|
||||||
|
artifact_id = str(uuid.uuid4())
|
||||||
|
content_digest = calculate_content_digest(content)
|
||||||
|
content_size = len(content.encode('utf-8'))
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
id=artifact_id,
|
||||||
|
space_id=space_id,
|
||||||
|
name=name,
|
||||||
|
artifact_type=artifact_type,
|
||||||
|
content_digest=content_digest,
|
||||||
|
content_size=content_size,
|
||||||
|
metadata=metadata or ArtifactMetadata(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_content(self, new_content: str) -> None:
|
||||||
|
"""
|
||||||
|
Update artifact content and recalculate digest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
new_content: New content string
|
||||||
|
"""
|
||||||
|
self.content_digest = calculate_content_digest(new_content)
|
||||||
|
self.content_size = len(new_content.encode('utf-8'))
|
||||||
|
self.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
def has_changed(self, current_digest: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if content has changed by comparing digests.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_digest: Digest to compare against
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if digests differ
|
||||||
|
"""
|
||||||
|
return self.content_digest != current_digest
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert artifact to dictionary for serialization."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"space_id": self.space_id,
|
||||||
|
"name": self.name,
|
||||||
|
"artifact_type": self.artifact_type.value,
|
||||||
|
"content_digest": self.content_digest,
|
||||||
|
"content_size": self.content_size,
|
||||||
|
"metadata": self.metadata.to_dict(),
|
||||||
|
"created_at": self.created_at.isoformat(),
|
||||||
|
"updated_at": self.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "Artifact":
|
||||||
|
"""Create artifact from dictionary."""
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
space_id=data["space_id"],
|
||||||
|
name=data["name"],
|
||||||
|
artifact_type=ArtifactType(data["artifact_type"]),
|
||||||
|
content_digest=data["content_digest"],
|
||||||
|
content_size=data.get("content_size", 0),
|
||||||
|
metadata=ArtifactMetadata.from_dict(data.get("metadata", {})),
|
||||||
|
created_at=datetime.fromisoformat(data["created_at"]),
|
||||||
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ArtifactReference:
|
||||||
|
"""
|
||||||
|
Reference to an artifact, possibly in another space.
|
||||||
|
|
||||||
|
Implements FR-1.3: Cross-space artifact references
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: Artifact name to resolve
|
||||||
|
space_id: Optional specific space ID (if not provided, uses resolution order)
|
||||||
|
version: Optional version constraint
|
||||||
|
"""
|
||||||
|
name: str
|
||||||
|
space_id: Optional[str] = None
|
||||||
|
version: Optional[str] = None
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""String representation for display and debugging."""
|
||||||
|
if self.space_id:
|
||||||
|
ref = f"{self.space_id}:{self.name}"
|
||||||
|
else:
|
||||||
|
ref = self.name
|
||||||
|
|
||||||
|
if self.version:
|
||||||
|
ref = f"{ref}@{self.version}"
|
||||||
|
|
||||||
|
return ref
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, reference_str: str) -> "ArtifactReference":
|
||||||
|
"""
|
||||||
|
Parse a reference string into components.
|
||||||
|
|
||||||
|
Formats:
|
||||||
|
- "artifact-name"
|
||||||
|
- "space-id:artifact-name"
|
||||||
|
- "artifact-name@version"
|
||||||
|
- "space-id:artifact-name@version"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reference_str: Reference string to parse
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed ArtifactReference
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If reference string is malformed
|
||||||
|
"""
|
||||||
|
# Split version if present
|
||||||
|
if '@' in reference_str:
|
||||||
|
ref_part, version = reference_str.rsplit('@', 1)
|
||||||
|
else:
|
||||||
|
ref_part, version = reference_str, None
|
||||||
|
|
||||||
|
# Split space if present
|
||||||
|
if ':' in ref_part:
|
||||||
|
space_id, name = ref_part.split(':', 1)
|
||||||
|
else:
|
||||||
|
space_id, name = None, ref_part
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
raise ValueError(f"Invalid artifact reference: {reference_str}")
|
||||||
|
|
||||||
|
return cls(name=name, space_id=space_id, version=version)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_content_digest(content: str) -> str:
|
||||||
|
"""
|
||||||
|
Calculate SHA-256 digest of content.
|
||||||
|
|
||||||
|
Implements FR-1.2: Content digest computation
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content string to hash
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hexadecimal SHA-256 digest
|
||||||
|
"""
|
||||||
|
return hashlib.sha256(content.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_bundle_digest(components: Dict[str, str]) -> str:
|
||||||
|
"""
|
||||||
|
Calculate digest of multiple components (for InputBundleHash).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
components: Dictionary of component name -> digest
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hexadecimal SHA-256 digest of sorted components
|
||||||
|
"""
|
||||||
|
# Sort components for deterministic hashing
|
||||||
|
sorted_items = sorted(components.items())
|
||||||
|
bundle_str = ':'.join(f"{k}={v}" for k, v in sorted_items)
|
||||||
|
return hashlib.sha256(bundle_str.encode('utf-8')).hexdigest()
|
||||||
20
markitect/prompts/repositories/__init__.py
Normal file
20
markitect/prompts/repositories/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
Repository layer for Prompt Dependency Resolution.
|
||||||
|
|
||||||
|
This package provides abstract interfaces and concrete implementations
|
||||||
|
for persisting prompts system entities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from markitect.prompts.repositories.interfaces import (
|
||||||
|
IArtifactRepository,
|
||||||
|
RepositoryError,
|
||||||
|
ArtifactNotFoundError,
|
||||||
|
DuplicateArtifactError,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"IArtifactRepository",
|
||||||
|
"RepositoryError",
|
||||||
|
"ArtifactNotFoundError",
|
||||||
|
"DuplicateArtifactError",
|
||||||
|
]
|
||||||
159
markitect/prompts/repositories/interfaces.py
Normal file
159
markitect/prompts/repositories/interfaces.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
Repository interfaces for artifact persistence.
|
||||||
|
|
||||||
|
Defines abstract interfaces for artifact storage, enabling
|
||||||
|
pluggable storage backends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import List, Optional
|
||||||
|
from markitect.prompts.models import Artifact, ArtifactType
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryError(Exception):
|
||||||
|
"""Base exception for repository errors."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactNotFoundError(RepositoryError):
|
||||||
|
"""Raised when an artifact cannot be found."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateArtifactError(RepositoryError):
|
||||||
|
"""Raised when attempting to create an artifact with duplicate name."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IArtifactRepository(ABC):
|
||||||
|
"""
|
||||||
|
Abstract interface for artifact persistence.
|
||||||
|
|
||||||
|
Implements FR-1: InformationSpace Addressability
|
||||||
|
Provides CRUD operations for artifacts with content digest tracking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create(self, artifact: Artifact) -> Artifact:
|
||||||
|
"""
|
||||||
|
Persist a new artifact.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artifact: Artifact to create
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created artifact
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DuplicateArtifactError: If artifact with same space_id+name exists
|
||||||
|
RepositoryError: On other persistence errors
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_by_id(self, artifact_id: str) -> Optional[Artifact]:
|
||||||
|
"""
|
||||||
|
Retrieve artifact by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artifact_id: Artifact identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Artifact if found, None otherwise
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_by_name(self, space_id: str, name: str) -> Optional[Artifact]:
|
||||||
|
"""
|
||||||
|
Retrieve artifact by space and name.
|
||||||
|
|
||||||
|
Implements FR-1.3: Cross-space artifact lookup
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: Space identifier
|
||||||
|
name: Artifact name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Artifact if found, None otherwise
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_by_digest(self, content_digest: str) -> List[Artifact]:
|
||||||
|
"""
|
||||||
|
Find artifacts with matching content digest.
|
||||||
|
|
||||||
|
Implements FR-1.2: Content digest queries
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_digest: SHA-256 digest to match
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of artifacts with matching digest
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_by_space(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
artifact_type: Optional[ArtifactType] = None,
|
||||||
|
) -> List[Artifact]:
|
||||||
|
"""
|
||||||
|
List all artifacts in a space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: Space identifier
|
||||||
|
artifact_type: Optional type filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of artifacts in space
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def update(self, artifact: Artifact) -> Artifact:
|
||||||
|
"""
|
||||||
|
Update an existing artifact.
|
||||||
|
|
||||||
|
Updates content digest and modified timestamp.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artifact: Artifact with updated data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated artifact
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ArtifactNotFoundError: If artifact doesn't exist
|
||||||
|
RepositoryError: On other persistence errors
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete(self, artifact_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete an artifact.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artifact_id: Artifact identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def exists(self, space_id: str, name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if artifact exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: Space identifier
|
||||||
|
name: Artifact name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if artifact exists
|
||||||
|
"""
|
||||||
|
pass
|
||||||
332
markitect/prompts/repositories/sqlite.py
Normal file
332
markitect/prompts/repositories/sqlite.py
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
"""
|
||||||
|
SQLite implementation of artifact repositories.
|
||||||
|
|
||||||
|
This module provides SQLite-backed implementations of the repository
|
||||||
|
interfaces for persistent storage of prompt artifacts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from markitect.prompts.repositories.interfaces import (
|
||||||
|
IArtifactRepository,
|
||||||
|
ArtifactNotFoundError,
|
||||||
|
DuplicateArtifactError,
|
||||||
|
RepositoryError,
|
||||||
|
)
|
||||||
|
from markitect.prompts.models import Artifact, ArtifactType, ArtifactMetadata
|
||||||
|
|
||||||
|
|
||||||
|
# SQL Schema for artifact tables
|
||||||
|
ARTIFACT_TABLES_SQL = """
|
||||||
|
-- Prompt artifacts table
|
||||||
|
CREATE TABLE IF NOT EXISTS prompt_artifacts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
space_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
artifact_type TEXT NOT NULL,
|
||||||
|
content_digest TEXT NOT NULL,
|
||||||
|
content_size INTEGER DEFAULT 0,
|
||||||
|
metadata JSON,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(space_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_artifacts_space ON prompt_artifacts(space_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_artifacts_digest ON prompt_artifacts(content_digest);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_artifacts_type ON prompt_artifacts(artifact_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_artifacts_name ON prompt_artifacts(space_id, name);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_artifact_tables(db_path: str) -> None:
|
||||||
|
"""
|
||||||
|
Initialize the artifact-related database tables.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to the SQLite database file
|
||||||
|
"""
|
||||||
|
# Ensure directory exists
|
||||||
|
db_dir = Path(db_path).parent
|
||||||
|
if db_dir and not db_dir.exists():
|
||||||
|
db_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
conn.executescript(ARTIFACT_TABLES_SQL)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteArtifactRepository(IArtifactRepository):
|
||||||
|
"""
|
||||||
|
SQLite implementation of artifact repository.
|
||||||
|
|
||||||
|
Provides persistent storage for artifacts with content digest tracking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str):
|
||||||
|
"""
|
||||||
|
Initialize repository with database path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to SQLite database file
|
||||||
|
"""
|
||||||
|
self.db_path = db_path
|
||||||
|
initialize_artifact_tables(db_path)
|
||||||
|
|
||||||
|
def _get_connection(self) -> sqlite3.Connection:
|
||||||
|
"""Get a database connection."""
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def create(self, artifact: Artifact) -> Artifact:
|
||||||
|
"""
|
||||||
|
Persist a new artifact.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artifact: Artifact to create
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created artifact
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DuplicateArtifactError: If artifact with same space_id+name exists
|
||||||
|
RepositoryError: On other persistence errors
|
||||||
|
"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO prompt_artifacts (
|
||||||
|
id, space_id, name, artifact_type, content_digest,
|
||||||
|
content_size, metadata, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
artifact.id,
|
||||||
|
artifact.space_id,
|
||||||
|
artifact.name,
|
||||||
|
artifact.artifact_type.value,
|
||||||
|
artifact.content_digest,
|
||||||
|
artifact.content_size,
|
||||||
|
json.dumps(artifact.metadata.to_dict()),
|
||||||
|
artifact.created_at.isoformat(),
|
||||||
|
artifact.updated_at.isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return artifact
|
||||||
|
except sqlite3.IntegrityError as e:
|
||||||
|
if "UNIQUE constraint" in str(e):
|
||||||
|
raise DuplicateArtifactError(
|
||||||
|
f"Artifact '{artifact.name}' already exists in space '{artifact.space_id}'"
|
||||||
|
)
|
||||||
|
raise RepositoryError(f"Database integrity error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
raise RepositoryError(f"Failed to create artifact: {e}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_by_id(self, artifact_id: str) -> Optional[Artifact]:
|
||||||
|
"""
|
||||||
|
Retrieve artifact by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artifact_id: Artifact identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Artifact if found, None otherwise
|
||||||
|
"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT * FROM prompt_artifacts WHERE id = ?",
|
||||||
|
(artifact_id,)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return self._row_to_artifact(row) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_by_name(self, space_id: str, name: str) -> Optional[Artifact]:
|
||||||
|
"""
|
||||||
|
Retrieve artifact by space and name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: Space identifier
|
||||||
|
name: Artifact name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Artifact if found, None otherwise
|
||||||
|
"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT * FROM prompt_artifacts WHERE space_id = ? AND name = ?",
|
||||||
|
(space_id, name)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return self._row_to_artifact(row) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_by_digest(self, content_digest: str) -> List[Artifact]:
|
||||||
|
"""
|
||||||
|
Find artifacts with matching content digest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_digest: SHA-256 digest to match
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of artifacts with matching digest
|
||||||
|
"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT * FROM prompt_artifacts WHERE content_digest = ?",
|
||||||
|
(content_digest,)
|
||||||
|
)
|
||||||
|
return [self._row_to_artifact(row) for row in cursor.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def list_by_space(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
artifact_type: Optional[ArtifactType] = None,
|
||||||
|
) -> List[Artifact]:
|
||||||
|
"""
|
||||||
|
List all artifacts in a space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: Space identifier
|
||||||
|
artifact_type: Optional type filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of artifacts in space
|
||||||
|
"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
if artifact_type:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT * FROM prompt_artifacts WHERE space_id = ? AND artifact_type = ? ORDER BY name",
|
||||||
|
(space_id, artifact_type.value)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT * FROM prompt_artifacts WHERE space_id = ? ORDER BY name",
|
||||||
|
(space_id,)
|
||||||
|
)
|
||||||
|
return [self._row_to_artifact(row) for row in cursor.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def update(self, artifact: Artifact) -> Artifact:
|
||||||
|
"""
|
||||||
|
Update an existing artifact.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artifact: Artifact with updated data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated artifact
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ArtifactNotFoundError: If artifact doesn't exist
|
||||||
|
RepositoryError: On other persistence errors
|
||||||
|
"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
# Update timestamp
|
||||||
|
artifact.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE prompt_artifacts
|
||||||
|
SET content_digest = ?, content_size = ?, metadata = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
artifact.content_digest,
|
||||||
|
artifact.content_size,
|
||||||
|
json.dumps(artifact.metadata.to_dict()),
|
||||||
|
artifact.updated_at.isoformat(),
|
||||||
|
artifact.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise ArtifactNotFoundError(f"Artifact with ID '{artifact.id}' not found")
|
||||||
|
conn.commit()
|
||||||
|
return artifact
|
||||||
|
except ArtifactNotFoundError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise RepositoryError(f"Failed to update artifact: {e}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def delete(self, artifact_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete an artifact.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artifact_id: Artifact identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found
|
||||||
|
"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"DELETE FROM prompt_artifacts WHERE id = ?",
|
||||||
|
(artifact_id,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def exists(self, space_id: str, name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if artifact exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: Space identifier
|
||||||
|
name: Artifact name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if artifact exists
|
||||||
|
"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM prompt_artifacts WHERE space_id = ? AND name = ?",
|
||||||
|
(space_id, name)
|
||||||
|
)
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
return count > 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _row_to_artifact(self, row: sqlite3.Row) -> Artifact:
|
||||||
|
"""Convert database row to Artifact instance."""
|
||||||
|
metadata_dict = json.loads(row["metadata"]) if row["metadata"] else {}
|
||||||
|
return Artifact(
|
||||||
|
id=row["id"],
|
||||||
|
space_id=row["space_id"],
|
||||||
|
name=row["name"],
|
||||||
|
artifact_type=ArtifactType(row["artifact_type"]),
|
||||||
|
content_digest=row["content_digest"],
|
||||||
|
content_size=row["content_size"],
|
||||||
|
metadata=ArtifactMetadata.from_dict(metadata_dict),
|
||||||
|
created_at=datetime.fromisoformat(row["created_at"]),
|
||||||
|
updated_at=datetime.fromisoformat(row["updated_at"]),
|
||||||
|
)
|
||||||
10
markitect/prompts/services/__init__.py
Normal file
10
markitect/prompts/services/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
Service layer for Prompt Dependency Resolution.
|
||||||
|
|
||||||
|
This package provides high-level business logic for artifact and template
|
||||||
|
management, orchestrating repositories and domain operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from markitect.prompts.services.artifact_service import ArtifactService
|
||||||
|
|
||||||
|
__all__ = ["ArtifactService"]
|
||||||
239
markitect/prompts/services/artifact_service.py
Normal file
239
markitect/prompts/services/artifact_service.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
"""
|
||||||
|
Artifact service for high-level artifact management operations.
|
||||||
|
|
||||||
|
This service provides business logic for creating, querying, and updating
|
||||||
|
artifacts with automatic content digest calculation and change tracking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from markitect.prompts.models import (
|
||||||
|
Artifact,
|
||||||
|
ArtifactType,
|
||||||
|
ArtifactMetadata,
|
||||||
|
ArtifactReference,
|
||||||
|
calculate_content_digest,
|
||||||
|
)
|
||||||
|
from markitect.prompts.repositories.interfaces import (
|
||||||
|
IArtifactRepository,
|
||||||
|
ArtifactNotFoundError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactService:
|
||||||
|
"""
|
||||||
|
Service for artifact management operations.
|
||||||
|
|
||||||
|
Provides high-level business logic on top of artifact repository,
|
||||||
|
handling content digest calculation, change detection, and cross-space
|
||||||
|
artifact resolution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, repository: IArtifactRepository):
|
||||||
|
"""
|
||||||
|
Initialize service with repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repository: Artifact repository implementation
|
||||||
|
"""
|
||||||
|
self.repository = repository
|
||||||
|
|
||||||
|
def create_artifact(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
name: str,
|
||||||
|
content: str,
|
||||||
|
artifact_type: ArtifactType = ArtifactType.CONTENT,
|
||||||
|
metadata: Optional[ArtifactMetadata] = None,
|
||||||
|
) -> Artifact:
|
||||||
|
"""
|
||||||
|
Create a new artifact with automatic digest calculation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: ID of containing space
|
||||||
|
name: Artifact name
|
||||||
|
content: Artifact content
|
||||||
|
artifact_type: Type classification
|
||||||
|
metadata: Optional metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created artifact
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DuplicateArtifactError: If artifact already exists
|
||||||
|
"""
|
||||||
|
artifact = Artifact.create(
|
||||||
|
space_id=space_id,
|
||||||
|
name=name,
|
||||||
|
content=content,
|
||||||
|
artifact_type=artifact_type,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
return self.repository.create(artifact)
|
||||||
|
|
||||||
|
def get_artifact(self, artifact_id: str) -> Artifact:
|
||||||
|
"""
|
||||||
|
Retrieve artifact by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artifact_id: Artifact identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Artifact instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ArtifactNotFoundError: If artifact doesn't exist
|
||||||
|
"""
|
||||||
|
artifact = self.repository.get_by_id(artifact_id)
|
||||||
|
if not artifact:
|
||||||
|
raise ArtifactNotFoundError(f"Artifact with ID '{artifact_id}' not found")
|
||||||
|
return artifact
|
||||||
|
|
||||||
|
def get_artifact_by_name(self, space_id: str, name: str) -> Artifact:
|
||||||
|
"""
|
||||||
|
Retrieve artifact by space and name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: Space identifier
|
||||||
|
name: Artifact name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Artifact instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ArtifactNotFoundError: If artifact doesn't exist
|
||||||
|
"""
|
||||||
|
artifact = self.repository.get_by_name(space_id, name)
|
||||||
|
if not artifact:
|
||||||
|
raise ArtifactNotFoundError(
|
||||||
|
f"Artifact '{name}' not found in space '{space_id}'"
|
||||||
|
)
|
||||||
|
return artifact
|
||||||
|
|
||||||
|
def resolve_reference(
|
||||||
|
self,
|
||||||
|
reference: ArtifactReference,
|
||||||
|
search_spaces: List[str],
|
||||||
|
) -> Optional[Artifact]:
|
||||||
|
"""
|
||||||
|
Resolve an artifact reference across multiple spaces.
|
||||||
|
|
||||||
|
Implements FR-1.3: Cross-space artifact references
|
||||||
|
Searches spaces in order until artifact is found.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reference: Artifact reference to resolve
|
||||||
|
search_spaces: List of space IDs to search in order
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Resolved artifact, or None if not found
|
||||||
|
"""
|
||||||
|
# If reference specifies space, search only that space
|
||||||
|
if reference.space_id:
|
||||||
|
return self.repository.get_by_name(reference.space_id, reference.name)
|
||||||
|
|
||||||
|
# Search spaces in order
|
||||||
|
for space_id in search_spaces:
|
||||||
|
artifact = self.repository.get_by_name(space_id, reference.name)
|
||||||
|
if artifact:
|
||||||
|
# TODO: Handle version constraint when versioning is implemented
|
||||||
|
return artifact
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update_artifact_content(
|
||||||
|
self,
|
||||||
|
artifact_id: str,
|
||||||
|
new_content: str,
|
||||||
|
) -> Artifact:
|
||||||
|
"""
|
||||||
|
Update artifact content with automatic digest recalculation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artifact_id: Artifact to update
|
||||||
|
new_content: New content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated artifact
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ArtifactNotFoundError: If artifact doesn't exist
|
||||||
|
"""
|
||||||
|
artifact = self.get_artifact(artifact_id)
|
||||||
|
artifact.update_content(new_content)
|
||||||
|
return self.repository.update(artifact)
|
||||||
|
|
||||||
|
def detect_change(self, artifact_id: str, current_content: str) -> bool:
|
||||||
|
"""
|
||||||
|
Detect if artifact content has changed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artifact_id: Artifact to check
|
||||||
|
current_content: Current content to compare
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if content has changed
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ArtifactNotFoundError: If artifact doesn't exist
|
||||||
|
"""
|
||||||
|
artifact = self.get_artifact(artifact_id)
|
||||||
|
current_digest = calculate_content_digest(current_content)
|
||||||
|
return artifact.has_changed(current_digest)
|
||||||
|
|
||||||
|
def list_artifacts(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
artifact_type: Optional[ArtifactType] = None,
|
||||||
|
) -> List[Artifact]:
|
||||||
|
"""
|
||||||
|
List artifacts in a space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: Space identifier
|
||||||
|
artifact_type: Optional type filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of artifacts
|
||||||
|
"""
|
||||||
|
return self.repository.list_by_space(space_id, artifact_type)
|
||||||
|
|
||||||
|
def find_by_digest(self, content_digest: str) -> List[Artifact]:
|
||||||
|
"""
|
||||||
|
Find all artifacts with matching content digest.
|
||||||
|
|
||||||
|
Useful for finding duplicate content across spaces.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_digest: SHA-256 digest to match
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of artifacts with matching digest
|
||||||
|
"""
|
||||||
|
return self.repository.get_by_digest(content_digest)
|
||||||
|
|
||||||
|
def delete_artifact(self, artifact_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete an artifact.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artifact_id: Artifact to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found
|
||||||
|
"""
|
||||||
|
return self.repository.delete(artifact_id)
|
||||||
|
|
||||||
|
def artifact_exists(self, space_id: str, name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if artifact exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: Space identifier
|
||||||
|
name: Artifact name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if artifact exists
|
||||||
|
"""
|
||||||
|
return self.repository.exists(space_id, name)
|
||||||
34
migrations/prompts/001_create_artifacts_table.sql
Normal file
34
migrations/prompts/001_create_artifacts_table.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
-- Migration 001: Create prompt artifacts table
|
||||||
|
-- Implements FR-1: InformationSpace Addressability
|
||||||
|
-- Date: 2026-02-08
|
||||||
|
|
||||||
|
-- Prompt artifacts table for content-based addressing
|
||||||
|
CREATE TABLE IF NOT EXISTS prompt_artifacts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
space_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
artifact_type TEXT NOT NULL,
|
||||||
|
content_digest TEXT NOT NULL,
|
||||||
|
content_size INTEGER DEFAULT 0,
|
||||||
|
metadata JSON,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(space_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_artifacts_space ON prompt_artifacts(space_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_artifacts_digest ON prompt_artifacts(content_digest);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_artifacts_type ON prompt_artifacts(artifact_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_artifacts_name ON prompt_artifacts(space_id, name);
|
||||||
|
|
||||||
|
-- Comments (for documentation)
|
||||||
|
-- prompt_artifacts.id: Unique UUID identifier for the artifact
|
||||||
|
-- prompt_artifacts.space_id: Reference to information space containing this artifact
|
||||||
|
-- prompt_artifacts.name: Human-readable name, unique within space
|
||||||
|
-- prompt_artifacts.artifact_type: Classification: content, template, generated, schema, config
|
||||||
|
-- prompt_artifacts.content_digest: SHA-256 hash of content for change detection
|
||||||
|
-- prompt_artifacts.content_size: Size of content in bytes
|
||||||
|
-- prompt_artifacts.metadata: JSON extensible metadata (description, tags, author, version, custom)
|
||||||
|
-- prompt_artifacts.created_at: Artifact creation timestamp
|
||||||
|
-- prompt_artifacts.updated_at: Last modification timestamp
|
||||||
1
tests/unit/prompts/__init__.py
Normal file
1
tests/unit/prompts/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Unit tests for Prompt Dependency Resolution."""
|
||||||
313
tests/unit/prompts/test_artifact_models.py
Normal file
313
tests/unit/prompts/test_artifact_models.py
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for artifact models.
|
||||||
|
|
||||||
|
Tests Artifact, ArtifactMetadata, ArtifactReference, and content digest
|
||||||
|
calculation functions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from markitect.prompts.models import (
|
||||||
|
Artifact,
|
||||||
|
ArtifactMetadata,
|
||||||
|
ArtifactReference,
|
||||||
|
ArtifactType,
|
||||||
|
calculate_content_digest,
|
||||||
|
calculate_bundle_digest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestArtifactMetadata:
|
||||||
|
"""Tests for ArtifactMetadata."""
|
||||||
|
|
||||||
|
def test_create_empty_metadata(self):
|
||||||
|
"""Test creating metadata with defaults."""
|
||||||
|
metadata = ArtifactMetadata()
|
||||||
|
assert metadata.description is None
|
||||||
|
assert metadata.tags == []
|
||||||
|
assert metadata.author is None
|
||||||
|
assert metadata.version is None
|
||||||
|
assert metadata.custom == {}
|
||||||
|
|
||||||
|
def test_create_metadata_with_values(self):
|
||||||
|
"""Test creating metadata with values."""
|
||||||
|
metadata = ArtifactMetadata(
|
||||||
|
description="Test artifact",
|
||||||
|
tags=["test", "example"],
|
||||||
|
author="test-author",
|
||||||
|
version="1.0.0",
|
||||||
|
custom={"key": "value"}
|
||||||
|
)
|
||||||
|
assert metadata.description == "Test artifact"
|
||||||
|
assert metadata.tags == ["test", "example"]
|
||||||
|
assert metadata.author == "test-author"
|
||||||
|
assert metadata.version == "1.0.0"
|
||||||
|
assert metadata.custom == {"key": "value"}
|
||||||
|
|
||||||
|
def test_metadata_to_dict(self):
|
||||||
|
"""Test metadata serialization to dict."""
|
||||||
|
metadata = ArtifactMetadata(
|
||||||
|
description="Test",
|
||||||
|
tags=["tag1"],
|
||||||
|
author="author",
|
||||||
|
)
|
||||||
|
data = metadata.to_dict()
|
||||||
|
assert data["description"] == "Test"
|
||||||
|
assert data["tags"] == ["tag1"]
|
||||||
|
assert data["author"] == "author"
|
||||||
|
assert data["version"] is None
|
||||||
|
assert data["custom"] == {}
|
||||||
|
|
||||||
|
def test_metadata_from_dict(self):
|
||||||
|
"""Test metadata deserialization from dict."""
|
||||||
|
data = {
|
||||||
|
"description": "Test",
|
||||||
|
"tags": ["tag1", "tag2"],
|
||||||
|
"author": "author",
|
||||||
|
"version": "2.0",
|
||||||
|
"custom": {"extra": "data"}
|
||||||
|
}
|
||||||
|
metadata = ArtifactMetadata.from_dict(data)
|
||||||
|
assert metadata.description == "Test"
|
||||||
|
assert metadata.tags == ["tag1", "tag2"]
|
||||||
|
assert metadata.author == "author"
|
||||||
|
assert metadata.version == "2.0"
|
||||||
|
assert metadata.custom == {"extra": "data"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestArtifact:
|
||||||
|
"""Tests for Artifact model."""
|
||||||
|
|
||||||
|
def test_create_artifact(self):
|
||||||
|
"""Test artifact creation with automatic digest calculation."""
|
||||||
|
content = "# Test Content\n\nThis is a test."
|
||||||
|
artifact = Artifact.create(
|
||||||
|
space_id="space-1",
|
||||||
|
name="test-artifact",
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert artifact.id # UUID generated
|
||||||
|
assert artifact.space_id == "space-1"
|
||||||
|
assert artifact.name == "test-artifact"
|
||||||
|
assert artifact.artifact_type == ArtifactType.CONTENT
|
||||||
|
assert artifact.content_digest == calculate_content_digest(content)
|
||||||
|
assert artifact.content_size == len(content.encode('utf-8'))
|
||||||
|
assert isinstance(artifact.metadata, ArtifactMetadata)
|
||||||
|
assert isinstance(artifact.created_at, datetime)
|
||||||
|
assert isinstance(artifact.updated_at, datetime)
|
||||||
|
|
||||||
|
def test_create_artifact_with_type(self):
|
||||||
|
"""Test creating artifact with specific type."""
|
||||||
|
artifact = Artifact.create(
|
||||||
|
space_id="space-1",
|
||||||
|
name="my-template",
|
||||||
|
content="Template content",
|
||||||
|
artifact_type=ArtifactType.TEMPLATE,
|
||||||
|
)
|
||||||
|
assert artifact.artifact_type == ArtifactType.TEMPLATE
|
||||||
|
|
||||||
|
def test_create_artifact_with_metadata(self):
|
||||||
|
"""Test creating artifact with metadata."""
|
||||||
|
metadata = ArtifactMetadata(description="Test", tags=["tag1"])
|
||||||
|
artifact = Artifact.create(
|
||||||
|
space_id="space-1",
|
||||||
|
name="test",
|
||||||
|
content="content",
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
assert artifact.metadata.description == "Test"
|
||||||
|
assert artifact.metadata.tags == ["tag1"]
|
||||||
|
|
||||||
|
def test_update_content(self):
|
||||||
|
"""Test updating artifact content."""
|
||||||
|
artifact = Artifact.create(
|
||||||
|
space_id="space-1",
|
||||||
|
name="test",
|
||||||
|
content="original content",
|
||||||
|
)
|
||||||
|
original_digest = artifact.content_digest
|
||||||
|
original_updated_at = artifact.updated_at
|
||||||
|
|
||||||
|
# Small delay to ensure timestamp difference
|
||||||
|
import time
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
new_content = "updated content"
|
||||||
|
artifact.update_content(new_content)
|
||||||
|
|
||||||
|
assert artifact.content_digest != original_digest
|
||||||
|
assert artifact.content_digest == calculate_content_digest(new_content)
|
||||||
|
assert artifact.content_size == len(new_content.encode('utf-8'))
|
||||||
|
assert artifact.updated_at > original_updated_at
|
||||||
|
|
||||||
|
def test_has_changed(self):
|
||||||
|
"""Test change detection."""
|
||||||
|
content = "original"
|
||||||
|
artifact = Artifact.create(
|
||||||
|
space_id="space-1",
|
||||||
|
name="test",
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Same content - no change
|
||||||
|
assert not artifact.has_changed(calculate_content_digest(content))
|
||||||
|
|
||||||
|
# Different content - changed
|
||||||
|
assert artifact.has_changed(calculate_content_digest("different"))
|
||||||
|
|
||||||
|
def test_artifact_to_dict(self):
|
||||||
|
"""Test artifact serialization."""
|
||||||
|
artifact = Artifact.create(
|
||||||
|
space_id="space-1",
|
||||||
|
name="test",
|
||||||
|
content="content",
|
||||||
|
artifact_type=ArtifactType.TEMPLATE,
|
||||||
|
)
|
||||||
|
data = artifact.to_dict()
|
||||||
|
|
||||||
|
assert data["id"] == artifact.id
|
||||||
|
assert data["space_id"] == "space-1"
|
||||||
|
assert data["name"] == "test"
|
||||||
|
assert data["artifact_type"] == "template"
|
||||||
|
assert data["content_digest"] == artifact.content_digest
|
||||||
|
assert data["content_size"] == artifact.content_size
|
||||||
|
assert "metadata" in data
|
||||||
|
assert "created_at" in data
|
||||||
|
assert "updated_at" in data
|
||||||
|
|
||||||
|
def test_artifact_from_dict(self):
|
||||||
|
"""Test artifact deserialization."""
|
||||||
|
original = Artifact.create(
|
||||||
|
space_id="space-1",
|
||||||
|
name="test",
|
||||||
|
content="content",
|
||||||
|
)
|
||||||
|
data = original.to_dict()
|
||||||
|
restored = Artifact.from_dict(data)
|
||||||
|
|
||||||
|
assert restored.id == original.id
|
||||||
|
assert restored.space_id == original.space_id
|
||||||
|
assert restored.name == original.name
|
||||||
|
assert restored.artifact_type == original.artifact_type
|
||||||
|
assert restored.content_digest == original.content_digest
|
||||||
|
assert restored.content_size == original.content_size
|
||||||
|
|
||||||
|
|
||||||
|
class TestArtifactReference:
|
||||||
|
"""Tests for ArtifactReference."""
|
||||||
|
|
||||||
|
def test_simple_reference(self):
|
||||||
|
"""Test simple artifact name reference."""
|
||||||
|
ref = ArtifactReference(name="my-artifact")
|
||||||
|
assert ref.name == "my-artifact"
|
||||||
|
assert ref.space_id is None
|
||||||
|
assert ref.version is None
|
||||||
|
assert str(ref) == "my-artifact"
|
||||||
|
|
||||||
|
def test_reference_with_space(self):
|
||||||
|
"""Test reference with space ID."""
|
||||||
|
ref = ArtifactReference(name="artifact", space_id="space-1")
|
||||||
|
assert ref.name == "artifact"
|
||||||
|
assert ref.space_id == "space-1"
|
||||||
|
assert str(ref) == "space-1:artifact"
|
||||||
|
|
||||||
|
def test_reference_with_version(self):
|
||||||
|
"""Test reference with version."""
|
||||||
|
ref = ArtifactReference(name="artifact", version="1.0.0")
|
||||||
|
assert ref.name == "artifact"
|
||||||
|
assert ref.version == "1.0.0"
|
||||||
|
assert str(ref) == "artifact@1.0.0"
|
||||||
|
|
||||||
|
def test_reference_with_space_and_version(self):
|
||||||
|
"""Test fully qualified reference."""
|
||||||
|
ref = ArtifactReference(
|
||||||
|
name="artifact",
|
||||||
|
space_id="space-1",
|
||||||
|
version="2.0.0"
|
||||||
|
)
|
||||||
|
assert str(ref) == "space-1:artifact@2.0.0"
|
||||||
|
|
||||||
|
def test_parse_simple_reference(self):
|
||||||
|
"""Test parsing simple reference."""
|
||||||
|
ref = ArtifactReference.parse("my-artifact")
|
||||||
|
assert ref.name == "my-artifact"
|
||||||
|
assert ref.space_id is None
|
||||||
|
assert ref.version is None
|
||||||
|
|
||||||
|
def test_parse_space_reference(self):
|
||||||
|
"""Test parsing space:artifact reference."""
|
||||||
|
ref = ArtifactReference.parse("space-1:my-artifact")
|
||||||
|
assert ref.name == "my-artifact"
|
||||||
|
assert ref.space_id == "space-1"
|
||||||
|
assert ref.version is None
|
||||||
|
|
||||||
|
def test_parse_version_reference(self):
|
||||||
|
"""Test parsing artifact@version reference."""
|
||||||
|
ref = ArtifactReference.parse("my-artifact@1.0.0")
|
||||||
|
assert ref.name == "my-artifact"
|
||||||
|
assert ref.space_id is None
|
||||||
|
assert ref.version == "1.0.0"
|
||||||
|
|
||||||
|
def test_parse_full_reference(self):
|
||||||
|
"""Test parsing space:artifact@version reference."""
|
||||||
|
ref = ArtifactReference.parse("space-1:my-artifact@2.0.0")
|
||||||
|
assert ref.name == "my-artifact"
|
||||||
|
assert ref.space_id == "space-1"
|
||||||
|
assert ref.version == "2.0.0"
|
||||||
|
|
||||||
|
def test_parse_invalid_reference(self):
|
||||||
|
"""Test parsing invalid reference raises error."""
|
||||||
|
with pytest.raises(ValueError, match="Invalid artifact reference"):
|
||||||
|
ArtifactReference.parse("space-1:")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Invalid artifact reference"):
|
||||||
|
ArtifactReference.parse("@1.0.0")
|
||||||
|
|
||||||
|
|
||||||
|
class TestContentDigest:
|
||||||
|
"""Tests for content digest functions."""
|
||||||
|
|
||||||
|
def test_calculate_content_digest(self):
|
||||||
|
"""Test SHA-256 digest calculation."""
|
||||||
|
content = "Hello, World!"
|
||||||
|
digest = calculate_content_digest(content)
|
||||||
|
# SHA-256 produces 64 hex characters
|
||||||
|
assert len(digest) == 64
|
||||||
|
assert digest.isalnum()
|
||||||
|
|
||||||
|
def test_digest_consistency(self):
|
||||||
|
"""Test digest is consistent for same content."""
|
||||||
|
content = "Test content"
|
||||||
|
digest1 = calculate_content_digest(content)
|
||||||
|
digest2 = calculate_content_digest(content)
|
||||||
|
assert digest1 == digest2
|
||||||
|
|
||||||
|
def test_digest_changes_with_content(self):
|
||||||
|
"""Test digest changes when content changes."""
|
||||||
|
digest1 = calculate_content_digest("content 1")
|
||||||
|
digest2 = calculate_content_digest("content 2")
|
||||||
|
assert digest1 != digest2
|
||||||
|
|
||||||
|
def test_calculate_bundle_digest(self):
|
||||||
|
"""Test bundle digest calculation."""
|
||||||
|
components = {
|
||||||
|
"template": "abc123",
|
||||||
|
"dep1": "def456",
|
||||||
|
"dep2": "ghi789",
|
||||||
|
}
|
||||||
|
digest = calculate_bundle_digest(components)
|
||||||
|
assert len(digest) == 64
|
||||||
|
|
||||||
|
def test_bundle_digest_order_independent(self):
|
||||||
|
"""Test bundle digest is deterministic regardless of dict order."""
|
||||||
|
components1 = {"a": "1", "b": "2", "c": "3"}
|
||||||
|
components2 = {"c": "3", "a": "1", "b": "2"}
|
||||||
|
assert calculate_bundle_digest(components1) == calculate_bundle_digest(components2)
|
||||||
|
|
||||||
|
def test_bundle_digest_sensitive_to_values(self):
|
||||||
|
"""Test bundle digest changes when values change."""
|
||||||
|
digest1 = calculate_bundle_digest({"a": "1", "b": "2"})
|
||||||
|
digest2 = calculate_bundle_digest({"a": "1", "b": "3"})
|
||||||
|
assert digest1 != digest2
|
||||||
261
tests/unit/prompts/test_artifact_repository.py
Normal file
261
tests/unit/prompts/test_artifact_repository.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for artifact repository.
|
||||||
|
|
||||||
|
Tests SQLiteArtifactRepository implementation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from markitect.prompts.models import Artifact, ArtifactType, ArtifactMetadata
|
||||||
|
from markitect.prompts.repositories.sqlite import SQLiteArtifactRepository
|
||||||
|
from markitect.prompts.repositories.interfaces import (
|
||||||
|
ArtifactNotFoundError,
|
||||||
|
DuplicateArtifactError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_db():
|
||||||
|
"""Create temporary database for testing."""
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||||
|
db_path = f.name
|
||||||
|
yield db_path
|
||||||
|
# Cleanup
|
||||||
|
Path(db_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def repository(temp_db):
|
||||||
|
"""Create repository instance with temp database."""
|
||||||
|
return SQLiteArtifactRepository(temp_db)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_artifact():
|
||||||
|
"""Create sample artifact for testing."""
|
||||||
|
return Artifact.create(
|
||||||
|
space_id="test-space",
|
||||||
|
name="test-artifact",
|
||||||
|
content="# Test Content\n\nThis is test content.",
|
||||||
|
artifact_type=ArtifactType.CONTENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSQLiteArtifactRepository:
|
||||||
|
"""Tests for SQLiteArtifactRepository."""
|
||||||
|
|
||||||
|
def test_create_artifact(self, repository, sample_artifact):
|
||||||
|
"""Test creating an artifact."""
|
||||||
|
created = repository.create(sample_artifact)
|
||||||
|
|
||||||
|
assert created.id == sample_artifact.id
|
||||||
|
assert created.space_id == sample_artifact.space_id
|
||||||
|
assert created.name == sample_artifact.name
|
||||||
|
assert created.content_digest == sample_artifact.content_digest
|
||||||
|
|
||||||
|
def test_create_duplicate_artifact_raises_error(self, repository, sample_artifact):
|
||||||
|
"""Test creating duplicate artifact raises error."""
|
||||||
|
repository.create(sample_artifact)
|
||||||
|
|
||||||
|
# Try to create another artifact with same space_id and name
|
||||||
|
duplicate = Artifact.create(
|
||||||
|
space_id="test-space",
|
||||||
|
name="test-artifact",
|
||||||
|
content="Different content",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(DuplicateArtifactError, match="already exists"):
|
||||||
|
repository.create(duplicate)
|
||||||
|
|
||||||
|
def test_get_by_id(self, repository, sample_artifact):
|
||||||
|
"""Test retrieving artifact by ID."""
|
||||||
|
repository.create(sample_artifact)
|
||||||
|
|
||||||
|
retrieved = repository.get_by_id(sample_artifact.id)
|
||||||
|
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.id == sample_artifact.id
|
||||||
|
assert retrieved.name == sample_artifact.name
|
||||||
|
assert retrieved.content_digest == sample_artifact.content_digest
|
||||||
|
|
||||||
|
def test_get_by_id_not_found(self, repository):
|
||||||
|
"""Test get_by_id returns None for non-existent ID."""
|
||||||
|
result = repository.get_by_id("non-existent-id")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_get_by_name(self, repository, sample_artifact):
|
||||||
|
"""Test retrieving artifact by space and name."""
|
||||||
|
repository.create(sample_artifact)
|
||||||
|
|
||||||
|
retrieved = repository.get_by_name("test-space", "test-artifact")
|
||||||
|
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.id == sample_artifact.id
|
||||||
|
assert retrieved.space_id == "test-space"
|
||||||
|
assert retrieved.name == "test-artifact"
|
||||||
|
|
||||||
|
def test_get_by_name_not_found(self, repository):
|
||||||
|
"""Test get_by_name returns None when not found."""
|
||||||
|
result = repository.get_by_name("test-space", "non-existent")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_get_by_digest(self, repository):
|
||||||
|
"""Test finding artifacts by content digest."""
|
||||||
|
content = "Shared content"
|
||||||
|
artifact1 = Artifact.create(
|
||||||
|
space_id="space-1",
|
||||||
|
name="artifact-1",
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
artifact2 = Artifact.create(
|
||||||
|
space_id="space-2",
|
||||||
|
name="artifact-2",
|
||||||
|
content=content, # Same content, same digest
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.create(artifact1)
|
||||||
|
repository.create(artifact2)
|
||||||
|
|
||||||
|
results = repository.get_by_digest(artifact1.content_digest)
|
||||||
|
|
||||||
|
assert len(results) == 2
|
||||||
|
names = {a.name for a in results}
|
||||||
|
assert names == {"artifact-1", "artifact-2"}
|
||||||
|
|
||||||
|
def test_list_by_space(self, repository):
|
||||||
|
"""Test listing artifacts in a space."""
|
||||||
|
artifacts = [
|
||||||
|
Artifact.create(
|
||||||
|
space_id="space-1",
|
||||||
|
name=f"artifact-{i}",
|
||||||
|
content=f"Content {i}",
|
||||||
|
)
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
|
||||||
|
for artifact in artifacts:
|
||||||
|
repository.create(artifact)
|
||||||
|
|
||||||
|
# Add artifact in different space
|
||||||
|
other = Artifact.create(
|
||||||
|
space_id="space-2",
|
||||||
|
name="other",
|
||||||
|
content="Other content",
|
||||||
|
)
|
||||||
|
repository.create(other)
|
||||||
|
|
||||||
|
results = repository.list_by_space("space-1")
|
||||||
|
|
||||||
|
assert len(results) == 3
|
||||||
|
assert all(a.space_id == "space-1" for a in results)
|
||||||
|
# Should be ordered by name
|
||||||
|
assert [a.name for a in results] == ["artifact-0", "artifact-1", "artifact-2"]
|
||||||
|
|
||||||
|
def test_list_by_space_with_type_filter(self, repository):
|
||||||
|
"""Test listing artifacts filtered by type."""
|
||||||
|
artifacts = [
|
||||||
|
Artifact.create(
|
||||||
|
space_id="space-1",
|
||||||
|
name="content-1",
|
||||||
|
content="Content",
|
||||||
|
artifact_type=ArtifactType.CONTENT,
|
||||||
|
),
|
||||||
|
Artifact.create(
|
||||||
|
space_id="space-1",
|
||||||
|
name="template-1",
|
||||||
|
content="Template",
|
||||||
|
artifact_type=ArtifactType.TEMPLATE,
|
||||||
|
),
|
||||||
|
Artifact.create(
|
||||||
|
space_id="space-1",
|
||||||
|
name="content-2",
|
||||||
|
content="Content 2",
|
||||||
|
artifact_type=ArtifactType.CONTENT,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for artifact in artifacts:
|
||||||
|
repository.create(artifact)
|
||||||
|
|
||||||
|
results = repository.list_by_space("space-1", ArtifactType.CONTENT)
|
||||||
|
|
||||||
|
assert len(results) == 2
|
||||||
|
assert all(a.artifact_type == ArtifactType.CONTENT for a in results)
|
||||||
|
|
||||||
|
def test_update_artifact(self, repository, sample_artifact):
|
||||||
|
"""Test updating artifact."""
|
||||||
|
repository.create(sample_artifact)
|
||||||
|
|
||||||
|
# Update content
|
||||||
|
sample_artifact.update_content("New content")
|
||||||
|
updated = repository.update(sample_artifact)
|
||||||
|
|
||||||
|
# Retrieve and verify
|
||||||
|
retrieved = repository.get_by_id(sample_artifact.id)
|
||||||
|
assert retrieved.content_digest == updated.content_digest
|
||||||
|
assert retrieved.updated_at >= updated.updated_at
|
||||||
|
|
||||||
|
def test_update_nonexistent_artifact_raises_error(self, repository):
|
||||||
|
"""Test updating non-existent artifact raises error."""
|
||||||
|
artifact = Artifact.create(
|
||||||
|
space_id="test-space",
|
||||||
|
name="test",
|
||||||
|
content="content",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ArtifactNotFoundError, match="not found"):
|
||||||
|
repository.update(artifact)
|
||||||
|
|
||||||
|
def test_delete_artifact(self, repository, sample_artifact):
|
||||||
|
"""Test deleting artifact."""
|
||||||
|
repository.create(sample_artifact)
|
||||||
|
|
||||||
|
result = repository.delete(sample_artifact.id)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
# Verify deleted
|
||||||
|
retrieved = repository.get_by_id(sample_artifact.id)
|
||||||
|
assert retrieved is None
|
||||||
|
|
||||||
|
def test_delete_nonexistent_artifact(self, repository):
|
||||||
|
"""Test deleting non-existent artifact returns False."""
|
||||||
|
result = repository.delete("non-existent-id")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_exists(self, repository, sample_artifact):
|
||||||
|
"""Test checking if artifact exists."""
|
||||||
|
assert not repository.exists("test-space", "test-artifact")
|
||||||
|
|
||||||
|
repository.create(sample_artifact)
|
||||||
|
|
||||||
|
assert repository.exists("test-space", "test-artifact")
|
||||||
|
assert not repository.exists("test-space", "other-artifact")
|
||||||
|
assert not repository.exists("other-space", "test-artifact")
|
||||||
|
|
||||||
|
def test_artifact_metadata_persistence(self, repository):
|
||||||
|
"""Test metadata is persisted correctly."""
|
||||||
|
metadata = ArtifactMetadata(
|
||||||
|
description="Test description",
|
||||||
|
tags=["tag1", "tag2"],
|
||||||
|
author="test-author",
|
||||||
|
version="1.0.0",
|
||||||
|
custom={"key": "value"},
|
||||||
|
)
|
||||||
|
|
||||||
|
artifact = Artifact.create(
|
||||||
|
space_id="test-space",
|
||||||
|
name="test",
|
||||||
|
content="content",
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.create(artifact)
|
||||||
|
retrieved = repository.get_by_id(artifact.id)
|
||||||
|
|
||||||
|
assert retrieved.metadata.description == "Test description"
|
||||||
|
assert retrieved.metadata.tags == ["tag1", "tag2"]
|
||||||
|
assert retrieved.metadata.author == "test-author"
|
||||||
|
assert retrieved.metadata.version == "1.0.0"
|
||||||
|
assert retrieved.metadata.custom == {"key": "value"}
|
||||||
Reference in New Issue
Block a user