Coverage for src / dataknobs_llm / prompts / versioning / version_manager.py: 18%

123 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-15 10:28 -0700

1"""Version management for prompts. 

2 

3This module provides version control capabilities including: 

4- Creating and retrieving prompt versions 

5- Semantic version management 

6- Version history tracking 

7- Tagging and status management 

8""" 

9 

10import re 

11import uuid 

12from typing import Any, Dict, List 

13from datetime import datetime 

14 

15from dataknobs_llm.exceptions import VersioningError 

16 

17from .types import ( 

18 PromptVersion, 

19 VersionStatus, 

20) 

21 

22 

23class VersionManager: 

24 """Manages prompt versions with semantic versioning. 

25 

26 Handles version creation, retrieval, and lifecycle management. 

27 Supports semantic versioning (major.minor.patch) and version tagging. 

28 

29 Example: 

30 ```python 

31 manager = VersionManager(storage_backend) 

32 

33 # Create a version 

34 v1 = await manager.create_version( 

35 name="greeting", 

36 prompt_type="system", 

37 template="Hello {{name}}!", 

38 version="1.0.0" 

39 ) 

40 

41 # Get latest version 

42 latest = await manager.get_version( 

43 name="greeting", 

44 prompt_type="system" 

45 ) 

46 

47 # Tag a version 

48 await manager.tag_version(v1.version_id, "production") 

49 ``` 

50 """ 

51 

52 # Semantic version pattern: major.minor.patch 

53 VERSION_PATTERN = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") 

54 

55 def __init__(self, storage: Any | None = None): 

56 """Initialize version manager. 

57 

58 Args: 

59 storage: Backend storage (dict for in-memory, database for persistence) 

60 If None, uses in-memory dictionary 

61 """ 

62 self.storage = storage if storage is not None else {} 

63 self._versions: Dict[str, PromptVersion] = {} # version_id -> PromptVersion 

64 self._version_index: Dict[str, List[str]] = {} # "{name}:{type}" -> [version_ids] 

65 

66 async def create_version( 

67 self, 

68 name: str, 

69 prompt_type: str, 

70 template: str, 

71 version: str | None = None, 

72 defaults: Dict[str, Any] | None = None, 

73 validation: Dict[str, Any] | None = None, 

74 metadata: Dict[str, Any] | None = None, 

75 created_by: str | None = None, 

76 parent_version: str | None = None, 

77 tags: List[str] | None = None, 

78 status: VersionStatus = VersionStatus.ACTIVE, 

79 ) -> PromptVersion: 

80 """Create a new prompt version. 

81 

82 Args: 

83 name: Prompt name 

84 prompt_type: Prompt type ("system", "user", "message") 

85 template: Template content 

86 version: Semantic version (e.g., "1.2.3"). If None, auto-increments from latest 

87 defaults: Default parameter values 

88 validation: Validation configuration 

89 metadata: Additional metadata 

90 created_by: Creator username/ID 

91 parent_version: Previous version ID for history tracking 

92 tags: List of tags 

93 status: Initial version status 

94 

95 Returns: 

96 Created PromptVersion 

97 

98 Raises: 

99 VersioningError: If version format is invalid or version already exists 

100 """ 

101 # Auto-increment version if not provided 

102 if version is None: 

103 version = await self._auto_increment_version(name, prompt_type) 

104 # If no parent_version specified, use the latest version 

105 if parent_version is None: 

106 latest = await self.get_version(name, prompt_type) 

107 if latest: 

108 parent_version = latest.version_id 

109 else: 

110 # Validate version format 

111 if not self.VERSION_PATTERN.match(version): 

112 raise VersioningError( 

113 f"Invalid version format: {version}. " 

114 f"Expected semantic version (e.g., '1.0.0')" 

115 ) 

116 

117 # Check if version already exists 

118 key = self._make_key(name, prompt_type) 

119 existing_versions = await self.list_versions(name, prompt_type) 

120 if any(v.version == version for v in existing_versions): 

121 raise VersioningError( 

122 f"Version {version} already exists for {name} ({prompt_type})" 

123 ) 

124 

125 # Generate unique version ID 

126 version_id = str(uuid.uuid4()) 

127 

128 # Create version object 

129 prompt_version = PromptVersion( 

130 version_id=version_id, 

131 name=name, 

132 prompt_type=prompt_type, 

133 version=version, 

134 template=template, 

135 defaults=defaults or {}, 

136 validation=validation, 

137 metadata=metadata or {}, 

138 created_at=datetime.utcnow(), 

139 created_by=created_by, 

140 parent_version=parent_version, 

141 tags=tags or [], 

142 status=status, 

143 ) 

144 

145 # Store version 

146 self._versions[version_id] = prompt_version 

147 

148 # Update index 

149 if key not in self._version_index: 

150 self._version_index[key] = [] 

151 self._version_index[key].append(version_id) 

152 

153 # Persist to backend if available 

154 if hasattr(self.storage, "set"): 

155 await self._persist_version(prompt_version) 

156 

157 return prompt_version 

158 

159 async def get_version( 

160 self, 

161 name: str, 

162 prompt_type: str, 

163 version: str = "latest", 

164 version_id: str | None = None, 

165 ) -> PromptVersion | None: 

166 """Retrieve a prompt version. 

167 

168 Args: 

169 name: Prompt name 

170 prompt_type: Prompt type 

171 version: Version string or "latest" for most recent 

172 version_id: Specific version ID (takes precedence over version) 

173 

174 Returns: 

175 PromptVersion if found, None otherwise 

176 """ 

177 # Direct lookup by version_id 

178 if version_id: 

179 return self._versions.get(version_id) 

180 

181 # Get all versions for this prompt 

182 versions = await self.list_versions(name, prompt_type) 

183 if not versions: 

184 return None 

185 

186 # Return latest version 

187 if version == "latest": 

188 return self._get_latest_version(versions) 

189 

190 # Find specific version 

191 for v in versions: 

192 if v.version == version: 

193 return v 

194 

195 return None 

196 

197 async def list_versions( 

198 self, 

199 name: str, 

200 prompt_type: str, 

201 tags: List[str] | None = None, 

202 status: VersionStatus | None = None, 

203 ) -> List[PromptVersion]: 

204 """List all versions of a prompt. 

205 

206 Args: 

207 name: Prompt name 

208 prompt_type: Prompt type 

209 tags: Filter by tags (returns versions with ANY of these tags) 

210 status: Filter by status 

211 

212 Returns: 

213 List of PromptVersion objects, sorted by version (newest first) 

214 """ 

215 key = self._make_key(name, prompt_type) 

216 version_ids = self._version_index.get(key, []) 

217 

218 versions = [self._versions[vid] for vid in version_ids] 

219 

220 # Apply filters 

221 if tags: 

222 versions = [v for v in versions if any(t in v.tags for t in tags)] 

223 

224 if status: 

225 versions = [v for v in versions if v.status == status] 

226 

227 # Sort by version (newest first) 

228 return sorted(versions, key=lambda v: self._parse_version(v.version), reverse=True) 

229 

230 async def tag_version( 

231 self, 

232 version_id: str, 

233 tag: str, 

234 ) -> PromptVersion: 

235 """Add a tag to a version. 

236 

237 Args: 

238 version_id: Version ID to tag 

239 tag: Tag to add (e.g., "production", "deprecated") 

240 

241 Returns: 

242 Updated PromptVersion 

243 

244 Raises: 

245 VersioningError: If version not found 

246 """ 

247 version = self._versions.get(version_id) 

248 if not version: 

249 raise VersioningError(f"Version not found: {version_id}") 

250 

251 if tag not in version.tags: 

252 version.tags.append(tag) 

253 

254 # Persist if backend available 

255 if hasattr(self.storage, "set"): 

256 await self._persist_version(version) 

257 

258 return version 

259 

260 async def untag_version( 

261 self, 

262 version_id: str, 

263 tag: str, 

264 ) -> PromptVersion: 

265 """Remove a tag from a version. 

266 

267 Args: 

268 version_id: Version ID 

269 tag: Tag to remove 

270 

271 Returns: 

272 Updated PromptVersion 

273 

274 Raises: 

275 VersioningError: If version not found 

276 """ 

277 version = self._versions.get(version_id) 

278 if not version: 

279 raise VersioningError(f"Version not found: {version_id}") 

280 

281 if tag in version.tags: 

282 version.tags.remove(tag) 

283 

284 # Persist if backend available 

285 if hasattr(self.storage, "set"): 

286 await self._persist_version(version) 

287 

288 return version 

289 

290 async def update_status( 

291 self, 

292 version_id: str, 

293 status: VersionStatus, 

294 ) -> PromptVersion: 

295 """Update version status. 

296 

297 Args: 

298 version_id: Version ID 

299 status: New status 

300 

301 Returns: 

302 Updated PromptVersion 

303 

304 Raises: 

305 VersioningError: If version not found 

306 """ 

307 version = self._versions.get(version_id) 

308 if not version: 

309 raise VersioningError(f"Version not found: {version_id}") 

310 

311 version.status = status 

312 

313 # Persist if backend available 

314 if hasattr(self.storage, "set"): 

315 await self._persist_version(version) 

316 

317 return version 

318 

319 async def delete_version( 

320 self, 

321 version_id: str, 

322 ) -> bool: 

323 """Delete a version. 

324 

325 Note: This permanently removes the version. Consider using 

326 update_status() with ARCHIVED instead. 

327 

328 Args: 

329 version_id: Version ID to delete 

330 

331 Returns: 

332 True if deleted, False if not found 

333 """ 

334 version = self._versions.get(version_id) 

335 if not version: 

336 return False 

337 

338 # Remove from index 

339 key = self._make_key(version.name, version.prompt_type) 

340 if key in self._version_index: 

341 self._version_index[key] = [ 

342 vid for vid in self._version_index[key] 

343 if vid != version_id 

344 ] 

345 

346 # Remove from storage 

347 del self._versions[version_id] 

348 

349 # Persist deletion if backend available 

350 if hasattr(self.storage, "delete"): 

351 await self.storage.delete(f"version:{version_id}") 

352 

353 return True 

354 

355 # ===== Helper Methods ===== 

356 

357 def _make_key(self, name: str, prompt_type: str) -> str: 

358 """Create index key for prompt name and type.""" 

359 return f"{name}:{prompt_type}" 

360 

361 def _parse_version(self, version: str) -> tuple: 

362 """Parse semantic version into (major, minor, patch) tuple.""" 

363 match = self.VERSION_PATTERN.match(version) 

364 if not match: 

365 return (0, 0, 0) 

366 return tuple(int(x) for x in match.groups()) 

367 

368 def _get_latest_version(self, versions: List[PromptVersion]) -> PromptVersion | None: 

369 """Get the latest version from a list.""" 

370 if not versions: 

371 return None 

372 

373 # Filter out archived/deprecated if active versions exist 

374 active = [v for v in versions if v.status in (VersionStatus.ACTIVE, VersionStatus.PRODUCTION)] 

375 if active: 

376 versions = active 

377 

378 # Sort by version number 

379 sorted_versions = sorted( 

380 versions, 

381 key=lambda v: self._parse_version(v.version), 

382 reverse=True 

383 ) 

384 return sorted_versions[0] 

385 

386 async def _auto_increment_version( 

387 self, 

388 name: str, 

389 prompt_type: str, 

390 ) -> str: 

391 """Auto-increment version number from latest version. 

392 

393 Increments patch version by default (1.0.0 -> 1.0.1). 

394 If no versions exist, returns "1.0.0". 

395 """ 

396 versions = await self.list_versions(name, prompt_type) 

397 if not versions: 

398 return "1.0.0" 

399 

400 latest = self._get_latest_version(versions) 

401 if not latest: 

402 return "1.0.0" 

403 

404 major, minor, patch = self._parse_version(latest.version) 

405 return f"{major}.{minor}.{patch + 1}" 

406 

407 async def _persist_version(self, version: PromptVersion): 

408 """Persist version to backend storage.""" 

409 if hasattr(self.storage, "set"): 

410 key = f"version:{version.version_id}" 

411 await self.storage.set(key, version.to_dict()) 

412 

413 async def get_version_history( 

414 self, 

415 name: str, 

416 prompt_type: str, 

417 ) -> List[PromptVersion]: 

418 """Get version history with parent relationships. 

419 

420 Returns versions in chronological order (oldest first). 

421 

422 Args: 

423 name: Prompt name 

424 prompt_type: Prompt type 

425 

426 Returns: 

427 List of versions in chronological order 

428 """ 

429 versions = await self.list_versions(name, prompt_type) 

430 return sorted(versions, key=lambda v: v.created_at)