mcpbr 0.4.16__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,470 @@
1
+ """Configuration migration tool for mcpbr.
2
+
3
+ Detects old config formats and migrates them to the current format.
4
+ Supports chained migrations (V1 -> V2 -> V3 -> V4_CURRENT), dry-run
5
+ preview, and automatic backup of originals.
6
+ """
7
+
8
+ import copy
9
+ import shutil
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+ from rich.table import Table
20
+
21
+
22
+ class ConfigVersion(Enum):
23
+ """Configuration format versions.
24
+
25
+ Each version corresponds to a major release era of mcpbr.
26
+ """
27
+
28
+ V1 = "v1" # Pre-0.3.0: api_key in config, "server" field
29
+ V2 = "v2" # 0.3.x: mcp_server, no benchmark field
30
+ V3 = "v3" # 0.4.x: benchmark field, sample_size, infrastructure
31
+ V4_CURRENT = "v4" # 0.5.0+: resource_limits, streaming config
32
+
33
+
34
+ @dataclass
35
+ class Migration:
36
+ """A single migration step between two config versions.
37
+
38
+ Attributes:
39
+ from_version: Source config version.
40
+ to_version: Target config version.
41
+ description: Human-readable description of what this migration does.
42
+ migrate: Callable that transforms a config dict from one version to the next.
43
+ """
44
+
45
+ from_version: ConfigVersion
46
+ to_version: ConfigVersion
47
+ description: str
48
+ migrate: Callable[[dict[str, Any]], dict[str, Any]]
49
+
50
+
51
+ @dataclass
52
+ class MigrationResult:
53
+ """Result of a config migration operation.
54
+
55
+ Attributes:
56
+ original_version: Detected version of the original config.
57
+ target_version: Target version after migration.
58
+ migrations_applied: List of migration descriptions that were applied.
59
+ warnings: List of warning messages (e.g., removed fields).
60
+ config: The migrated config dictionary.
61
+ changes_preview: List of human-readable change descriptions for dry-run.
62
+ """
63
+
64
+ original_version: ConfigVersion
65
+ target_version: ConfigVersion
66
+ migrations_applied: list[str] = field(default_factory=list)
67
+ warnings: list[str] = field(default_factory=list)
68
+ config: dict[str, Any] = field(default_factory=dict)
69
+ changes_preview: list[str] = field(default_factory=list)
70
+
71
+
72
+ class MigrationChain:
73
+ """Manages a chain of config migrations from any old version to current.
74
+
75
+ Registers all known migration steps and provides methods to detect
76
+ config versions, find applicable migrations, and execute them.
77
+ """
78
+
79
+ def __init__(self) -> None:
80
+ """Initialize the migration chain with all known migrations."""
81
+ self._migrations: list[Migration] = []
82
+ self._register_all_migrations()
83
+
84
+ def _register_all_migrations(self) -> None:
85
+ """Register all known migration steps."""
86
+ self._migrations.append(
87
+ Migration(
88
+ from_version=ConfigVersion.V1,
89
+ to_version=ConfigVersion.V2,
90
+ description="V1 -> V2: Move api_key to env vars, rename 'server' to 'mcp_server'",
91
+ migrate=self._migrate_v1_to_v2,
92
+ )
93
+ )
94
+ self._migrations.append(
95
+ Migration(
96
+ from_version=ConfigVersion.V2,
97
+ to_version=ConfigVersion.V3,
98
+ description=(
99
+ "V2 -> V3: Add benchmark field, rename max_tasks to sample_size, "
100
+ "add infrastructure section"
101
+ ),
102
+ migrate=self._migrate_v2_to_v3,
103
+ )
104
+ )
105
+ self._migrations.append(
106
+ Migration(
107
+ from_version=ConfigVersion.V3,
108
+ to_version=ConfigVersion.V4_CURRENT,
109
+ description=(
110
+ "V3 -> V4: Add resource_limits defaults, add streaming config section"
111
+ ),
112
+ migrate=self._migrate_v3_to_v4,
113
+ )
114
+ )
115
+
116
+ def detect_version(self, config: dict[str, Any]) -> ConfigVersion:
117
+ """Detect the config version based on field presence/absence.
118
+
119
+ Detection heuristics (checked in order, most specific first):
120
+ - V1: Has "api_key" or "server" (not "mcp_server") top-level key.
121
+ - V4_CURRENT: Has "resource_limits" or "streaming" key, or
122
+ none of the older markers are present.
123
+ - V2: Has "mcp_server" but no "benchmark" or "infrastructure" key.
124
+ - V3: Has "mcp_server" and "benchmark" or "infrastructure" but
125
+ no "resource_limits" or "streaming" key.
126
+
127
+ Args:
128
+ config: Parsed config dictionary.
129
+
130
+ Returns:
131
+ Detected ConfigVersion.
132
+ """
133
+ has_api_key = "api_key" in config
134
+ has_server = "server" in config and "mcp_server" not in config
135
+ has_mcp_server = "mcp_server" in config
136
+ has_benchmark = "benchmark" in config
137
+ has_infrastructure = "infrastructure" in config
138
+ has_resource_limits = "resource_limits" in config
139
+ has_streaming = "streaming" in config
140
+
141
+ # V1: legacy fields present
142
+ if has_api_key or has_server:
143
+ return ConfigVersion.V1
144
+
145
+ # V4: has current-version fields (check before V2/V3 to avoid false matches)
146
+ if has_resource_limits or has_streaming:
147
+ return ConfigVersion.V4_CURRENT
148
+
149
+ # V2: has mcp_server but no benchmark/infrastructure
150
+ if has_mcp_server and not has_benchmark and not has_infrastructure:
151
+ return ConfigVersion.V2
152
+
153
+ # V3: has mcp_server with benchmark or infrastructure but not V4 fields
154
+ if has_mcp_server and (has_benchmark or has_infrastructure):
155
+ return ConfigVersion.V3
156
+
157
+ # Default to current if no distinguishing markers found
158
+ return ConfigVersion.V4_CURRENT
159
+
160
+ def get_migrations(self, from_ver: ConfigVersion, to_ver: ConfigVersion) -> list[Migration]:
161
+ """Get the ordered list of migrations needed to go from one version to another.
162
+
163
+ Args:
164
+ from_ver: Starting config version.
165
+ to_ver: Target config version.
166
+
167
+ Returns:
168
+ Ordered list of Migration objects to apply. Empty list if no
169
+ migration is needed or if from_ver >= to_ver.
170
+ """
171
+ version_order = list(ConfigVersion)
172
+ from_idx = version_order.index(from_ver)
173
+ to_idx = version_order.index(to_ver)
174
+
175
+ if from_idx >= to_idx:
176
+ return []
177
+
178
+ result: list[Migration] = []
179
+ current = from_ver
180
+ for migration in self._migrations:
181
+ if (
182
+ migration.from_version == current
183
+ and version_order.index(migration.to_version) <= to_idx
184
+ ):
185
+ result.append(migration)
186
+ current = migration.to_version
187
+
188
+ return result
189
+
190
+ def migrate(self, config: dict[str, Any], dry_run: bool = False) -> MigrationResult:
191
+ """Migrate a config dict from its detected version to the current version.
192
+
193
+ Args:
194
+ config: Parsed config dictionary to migrate.
195
+ dry_run: If True, compute changes but do not modify the config.
196
+
197
+ Returns:
198
+ MigrationResult with migration details and the (possibly modified) config.
199
+ """
200
+ original_version = self.detect_version(config)
201
+ target_version = ConfigVersion.V4_CURRENT
202
+
203
+ result = MigrationResult(
204
+ original_version=original_version,
205
+ target_version=target_version,
206
+ config=copy.deepcopy(config),
207
+ )
208
+
209
+ if original_version == target_version:
210
+ result.changes_preview.append("Config is already at the current version (V4).")
211
+ return result
212
+
213
+ migrations = self.get_migrations(original_version, target_version)
214
+ if not migrations:
215
+ result.warnings.append(
216
+ f"No migration path found from {original_version.value} to {target_version.value}."
217
+ )
218
+ return result
219
+
220
+ working_config = copy.deepcopy(config)
221
+
222
+ for migration in migrations:
223
+ result.changes_preview.append(f"Apply: {migration.description}")
224
+ if not dry_run:
225
+ working_config = migration.migrate(working_config)
226
+ result.migrations_applied.append(migration.description)
227
+
228
+ if not dry_run:
229
+ result.config = working_config
230
+ else:
231
+ # For dry-run, keep original config but show what would change
232
+ result.config = copy.deepcopy(config)
233
+
234
+ return result
235
+
236
+ # -- Individual migration implementations --
237
+
238
+ @staticmethod
239
+ def _migrate_v1_to_v2(config: dict[str, Any]) -> dict[str, Any]:
240
+ """Migrate V1 config to V2 format.
241
+
242
+ Changes:
243
+ - Remove "api_key" (moved to environment variables).
244
+ - Rename "server" to "mcp_server" and convert to new structure.
245
+ - Remove "dataset" if present (replaced by benchmark in V3).
246
+
247
+ Args:
248
+ config: V1 config dictionary.
249
+
250
+ Returns:
251
+ V2 config dictionary.
252
+ """
253
+ result = copy.deepcopy(config)
254
+
255
+ # Remove api_key (should be in env vars now)
256
+ if "api_key" in result:
257
+ del result["api_key"]
258
+
259
+ # Rename "server" to "mcp_server" with structure conversion
260
+ if "server" in result:
261
+ server = result.pop("server")
262
+ if isinstance(server, dict):
263
+ # Convert old server format to mcp_server format
264
+ mcp_server: dict[str, Any] = {}
265
+ if "command" in server:
266
+ mcp_server["command"] = server["command"]
267
+ if "args" in server:
268
+ mcp_server["args"] = server["args"]
269
+ if "env" in server:
270
+ mcp_server["env"] = server["env"]
271
+ if "name" in server:
272
+ mcp_server["name"] = server["name"]
273
+ result["mcp_server"] = mcp_server
274
+ elif isinstance(server, str):
275
+ # Simple string server specification -> convert to command
276
+ result["mcp_server"] = {"command": server, "args": []}
277
+
278
+ return result
279
+
280
+ @staticmethod
281
+ def _migrate_v2_to_v3(config: dict[str, Any]) -> dict[str, Any]:
282
+ """Migrate V2 config to V3 format.
283
+
284
+ Changes:
285
+ - Add "benchmark" field (default: "swe-bench-verified").
286
+ - Rename "max_tasks" to "sample_size".
287
+ - Add "infrastructure" section with default local mode.
288
+ - Convert "dataset" field to "benchmark" if present.
289
+
290
+ Args:
291
+ config: V2 config dictionary.
292
+
293
+ Returns:
294
+ V3 config dictionary.
295
+ """
296
+ result = copy.deepcopy(config)
297
+
298
+ # Map old dataset names to benchmark identifiers
299
+ dataset_to_benchmark = {
300
+ "SWE-bench/SWE-bench_Lite": "swe-bench-lite",
301
+ "SWE-bench/SWE-bench_Verified": "swe-bench-verified",
302
+ "SWE-bench/SWE-bench": "swe-bench-full",
303
+ "princeton-nlp/SWE-bench_Lite": "swe-bench-lite",
304
+ "princeton-nlp/SWE-bench_Verified": "swe-bench-verified",
305
+ "princeton-nlp/SWE-bench": "swe-bench-full",
306
+ }
307
+
308
+ # Convert dataset to benchmark if present
309
+ if "dataset" in result:
310
+ dataset_val = result.pop("dataset")
311
+ if dataset_val in dataset_to_benchmark:
312
+ result["benchmark"] = dataset_to_benchmark[dataset_val]
313
+ else:
314
+ result["benchmark"] = "swe-bench-verified"
315
+
316
+ # Add benchmark default if not present
317
+ if "benchmark" not in result:
318
+ result["benchmark"] = "swe-bench-verified"
319
+
320
+ # Rename max_tasks to sample_size
321
+ if "max_tasks" in result:
322
+ result["sample_size"] = result.pop("max_tasks")
323
+
324
+ # Add infrastructure section if not present
325
+ if "infrastructure" not in result:
326
+ result["infrastructure"] = {"mode": "local"}
327
+
328
+ return result
329
+
330
+ @staticmethod
331
+ def _migrate_v3_to_v4(config: dict[str, Any]) -> dict[str, Any]:
332
+ """Migrate V3 config to V4 (current) format.
333
+
334
+ Changes:
335
+ - Add "resource_limits" section with defaults.
336
+ - Add "streaming" config section.
337
+
338
+ Args:
339
+ config: V3 config dictionary.
340
+
341
+ Returns:
342
+ V4 config dictionary.
343
+ """
344
+ result = copy.deepcopy(config)
345
+
346
+ # Add resource_limits with sensible defaults
347
+ if "resource_limits" not in result:
348
+ result["resource_limits"] = {
349
+ "max_memory_mb": 4096,
350
+ "max_cpu_percent": 80,
351
+ "max_disk_mb": 10240,
352
+ }
353
+
354
+ # Add streaming config section
355
+ if "streaming" not in result:
356
+ result["streaming"] = {
357
+ "enabled": True,
358
+ "console_updates": True,
359
+ "progressive_json": None,
360
+ "progressive_yaml": None,
361
+ "progressive_markdown": None,
362
+ }
363
+
364
+ return result
365
+
366
+
367
+ def migrate_config_file(path: Path, dry_run: bool = False, backup: bool = True) -> MigrationResult:
368
+ """Migrate a config file from an old format to the current format.
369
+
370
+ Reads the YAML file, detects its version, applies all necessary
371
+ migrations, and writes back the updated config. Optionally creates
372
+ a backup of the original file.
373
+
374
+ Args:
375
+ path: Path to the config YAML file.
376
+ dry_run: If True, preview changes without modifying the file.
377
+ backup: If True, create a backup (.bak) of the original before writing.
378
+
379
+ Returns:
380
+ MigrationResult with details of the migration.
381
+
382
+ Raises:
383
+ FileNotFoundError: If the config file does not exist.
384
+ yaml.YAMLError: If the file cannot be parsed as YAML.
385
+ """
386
+ path = Path(path)
387
+ if not path.exists():
388
+ raise FileNotFoundError(f"Config file not found: {path}")
389
+
390
+ with open(path) as f:
391
+ config = yaml.safe_load(f) or {}
392
+
393
+ chain = MigrationChain()
394
+ result = chain.migrate(config, dry_run=dry_run)
395
+
396
+ if dry_run:
397
+ return result
398
+
399
+ # No migrations needed
400
+ if result.original_version == result.target_version:
401
+ return result
402
+
403
+ # Create backup before writing
404
+ if backup:
405
+ backup_path = path.with_suffix(path.suffix + ".bak")
406
+ shutil.copy2(path, backup_path)
407
+ result.changes_preview.append(f"Backup created: {backup_path}")
408
+
409
+ # Write migrated config
410
+ with open(path, "w") as f:
411
+ yaml.dump(
412
+ result.config,
413
+ f,
414
+ default_flow_style=False,
415
+ sort_keys=False,
416
+ allow_unicode=True,
417
+ )
418
+
419
+ return result
420
+
421
+
422
+ def format_migration_report(result: MigrationResult) -> None:
423
+ """Print a rich-formatted migration report to the console.
424
+
425
+ Displays the detected version, target version, applied migrations,
426
+ warnings, and a preview of changes.
427
+
428
+ Args:
429
+ result: MigrationResult to format and display.
430
+ """
431
+ console = Console(stderr=True)
432
+
433
+ # Header
434
+ title = f"Config Migration: {result.original_version.value} -> {result.target_version.value}"
435
+
436
+ if result.original_version == result.target_version:
437
+ console.print(
438
+ Panel(
439
+ "[green]Config is already at the current version. No migration needed.[/green]",
440
+ title=title,
441
+ )
442
+ )
443
+ return
444
+
445
+ # Migrations table
446
+ if result.migrations_applied:
447
+ table = Table(title="Migrations Applied")
448
+ table.add_column("#", style="dim", width=4)
449
+ table.add_column("Description", style="cyan")
450
+
451
+ for i, desc in enumerate(result.migrations_applied, 1):
452
+ table.add_row(str(i), desc)
453
+
454
+ console.print(table)
455
+
456
+ # Warnings
457
+ if result.warnings:
458
+ console.print()
459
+ console.print("[yellow]Warnings:[/yellow]")
460
+ for warning in result.warnings:
461
+ console.print(f" [yellow]- {warning}[/yellow]")
462
+
463
+ # Changes preview
464
+ if result.changes_preview:
465
+ console.print()
466
+ console.print("[blue]Changes:[/blue]")
467
+ for change in result.changes_preview:
468
+ console.print(f" [blue]- {change}[/blue]")
469
+
470
+ console.print()