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

122 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-08 13:51 -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 .types import ( 

16 PromptVersion, 

17 VersionStatus, 

18 VersioningError, 

19) 

20 

21 

22class VersionManager: 

23 """Manages prompt versions with semantic versioning. 

24 

25 Handles version creation, retrieval, and lifecycle management. 

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

27 

28 Example: 

29 ```python 

30 manager = VersionManager(storage_backend) 

31 

32 # Create a version 

33 v1 = await manager.create_version( 

34 name="greeting", 

35 prompt_type="system", 

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

37 version="1.0.0" 

38 ) 

39 

40 # Get latest version 

41 latest = await manager.get_version( 

42 name="greeting", 

43 prompt_type="system" 

44 ) 

45 

46 # Tag a version 

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

48 ``` 

49 """ 

50 

51 # Semantic version pattern: major.minor.patch 

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

53 

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

55 """Initialize version manager. 

56 

57 Args: 

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

59 If None, uses in-memory dictionary 

60 """ 

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

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

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

64 

65 async def create_version( 

66 self, 

67 name: str, 

68 prompt_type: str, 

69 template: str, 

70 version: str | None = None, 

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

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

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

74 created_by: str | None = None, 

75 parent_version: str | None = None, 

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

77 status: VersionStatus = VersionStatus.ACTIVE, 

78 ) -> PromptVersion: 

79 """Create a new prompt version. 

80 

81 Args: 

82 name: Prompt name 

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

84 template: Template content 

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

86 defaults: Default parameter values 

87 validation: Validation configuration 

88 metadata: Additional metadata 

89 created_by: Creator username/ID 

90 parent_version: Previous version ID for history tracking 

91 tags: List of tags 

92 status: Initial version status 

93 

94 Returns: 

95 Created PromptVersion 

96 

97 Raises: 

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

99 """ 

100 # Auto-increment version if not provided 

101 if version is None: 

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

103 # If no parent_version specified, use the latest version 

104 if parent_version is None: 

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

106 if latest: 

107 parent_version = latest.version_id 

108 else: 

109 # Validate version format 

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

111 raise VersioningError( 

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

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

114 ) 

115 

116 # Check if version already exists 

117 key = self._make_key(name, prompt_type) 

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

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

120 raise VersioningError( 

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

122 ) 

123 

124 # Generate unique version ID 

125 version_id = str(uuid.uuid4()) 

126 

127 # Create version object 

128 prompt_version = PromptVersion( 

129 version_id=version_id, 

130 name=name, 

131 prompt_type=prompt_type, 

132 version=version, 

133 template=template, 

134 defaults=defaults or {}, 

135 validation=validation, 

136 metadata=metadata or {}, 

137 created_at=datetime.utcnow(), 

138 created_by=created_by, 

139 parent_version=parent_version, 

140 tags=tags or [], 

141 status=status, 

142 ) 

143 

144 # Store version 

145 self._versions[version_id] = prompt_version 

146 

147 # Update index 

148 if key not in self._version_index: 

149 self._version_index[key] = [] 

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

151 

152 # Persist to backend if available 

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

154 await self._persist_version(prompt_version) 

155 

156 return prompt_version 

157 

158 async def get_version( 

159 self, 

160 name: str, 

161 prompt_type: str, 

162 version: str = "latest", 

163 version_id: str | None = None, 

164 ) -> PromptVersion | None: 

165 """Retrieve a prompt version. 

166 

167 Args: 

168 name: Prompt name 

169 prompt_type: Prompt type 

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

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

172 

173 Returns: 

174 PromptVersion if found, None otherwise 

175 """ 

176 # Direct lookup by version_id 

177 if version_id: 

178 return self._versions.get(version_id) 

179 

180 # Get all versions for this prompt 

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

182 if not versions: 

183 return None 

184 

185 # Return latest version 

186 if version == "latest": 

187 return self._get_latest_version(versions) 

188 

189 # Find specific version 

190 for v in versions: 

191 if v.version == version: 

192 return v 

193 

194 return None 

195 

196 async def list_versions( 

197 self, 

198 name: str, 

199 prompt_type: str, 

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

201 status: VersionStatus | None = None, 

202 ) -> List[PromptVersion]: 

203 """List all versions of a prompt. 

204 

205 Args: 

206 name: Prompt name 

207 prompt_type: Prompt type 

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

209 status: Filter by status 

210 

211 Returns: 

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

213 """ 

214 key = self._make_key(name, prompt_type) 

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

216 

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

218 

219 # Apply filters 

220 if tags: 

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

222 

223 if status: 

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

225 

226 # Sort by version (newest first) 

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

228 

229 async def tag_version( 

230 self, 

231 version_id: str, 

232 tag: str, 

233 ) -> PromptVersion: 

234 """Add a tag to a version. 

235 

236 Args: 

237 version_id: Version ID to tag 

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

239 

240 Returns: 

241 Updated PromptVersion 

242 

243 Raises: 

244 VersioningError: If version not found 

245 """ 

246 version = self._versions.get(version_id) 

247 if not version: 

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

249 

250 if tag not in version.tags: 

251 version.tags.append(tag) 

252 

253 # Persist if backend available 

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

255 await self._persist_version(version) 

256 

257 return version 

258 

259 async def untag_version( 

260 self, 

261 version_id: str, 

262 tag: str, 

263 ) -> PromptVersion: 

264 """Remove a tag from a version. 

265 

266 Args: 

267 version_id: Version ID 

268 tag: Tag to remove 

269 

270 Returns: 

271 Updated PromptVersion 

272 

273 Raises: 

274 VersioningError: If version not found 

275 """ 

276 version = self._versions.get(version_id) 

277 if not version: 

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

279 

280 if tag in version.tags: 

281 version.tags.remove(tag) 

282 

283 # Persist if backend available 

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

285 await self._persist_version(version) 

286 

287 return version 

288 

289 async def update_status( 

290 self, 

291 version_id: str, 

292 status: VersionStatus, 

293 ) -> PromptVersion: 

294 """Update version status. 

295 

296 Args: 

297 version_id: Version ID 

298 status: New status 

299 

300 Returns: 

301 Updated PromptVersion 

302 

303 Raises: 

304 VersioningError: If version not found 

305 """ 

306 version = self._versions.get(version_id) 

307 if not version: 

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

309 

310 version.status = status 

311 

312 # Persist if backend available 

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

314 await self._persist_version(version) 

315 

316 return version 

317 

318 async def delete_version( 

319 self, 

320 version_id: str, 

321 ) -> bool: 

322 """Delete a version. 

323 

324 Note: This permanently removes the version. Consider using 

325 update_status() with ARCHIVED instead. 

326 

327 Args: 

328 version_id: Version ID to delete 

329 

330 Returns: 

331 True if deleted, False if not found 

332 """ 

333 version = self._versions.get(version_id) 

334 if not version: 

335 return False 

336 

337 # Remove from index 

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

339 if key in self._version_index: 

340 self._version_index[key] = [ 

341 vid for vid in self._version_index[key] 

342 if vid != version_id 

343 ] 

344 

345 # Remove from storage 

346 del self._versions[version_id] 

347 

348 # Persist deletion if backend available 

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

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

351 

352 return True 

353 

354 # ===== Helper Methods ===== 

355 

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

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

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

359 

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

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

362 match = self.VERSION_PATTERN.match(version) 

363 if not match: 

364 return (0, 0, 0) 

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

366 

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

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

369 if not versions: 

370 return None 

371 

372 # Filter out archived/deprecated if active versions exist 

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

374 if active: 

375 versions = active 

376 

377 # Sort by version number 

378 sorted_versions = sorted( 

379 versions, 

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

381 reverse=True 

382 ) 

383 return sorted_versions[0] 

384 

385 async def _auto_increment_version( 

386 self, 

387 name: str, 

388 prompt_type: str, 

389 ) -> str: 

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

391 

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

393 If no versions exist, returns "1.0.0". 

394 """ 

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

396 if not versions: 

397 return "1.0.0" 

398 

399 latest = self._get_latest_version(versions) 

400 if not latest: 

401 return "1.0.0" 

402 

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

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

405 

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

407 """Persist version to backend storage.""" 

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

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

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

411 

412 async def get_version_history( 

413 self, 

414 name: str, 

415 prompt_type: str, 

416 ) -> List[PromptVersion]: 

417 """Get version history with parent relationships. 

418 

419 Returns versions in chronological order (oldest first). 

420 

421 Args: 

422 name: Prompt name 

423 prompt_type: Prompt type 

424 

425 Returns: 

426 List of versions in chronological order 

427 """ 

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

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