|
1 | 1 | """Spec command for managing spec syncs with the MongoDB specifications repository.""" |
2 | 2 |
|
| 3 | +import filecmp |
3 | 4 | import os |
| 5 | +import re |
4 | 6 | import subprocess |
5 | 7 | from pathlib import Path |
6 | 8 |
|
@@ -259,10 +261,313 @@ def spec_sync( |
259 | 261 |
|
260 | 262 |
|
261 | 263 | # --------------------------------------------------------------------------- |
262 | | -# dbx spec list |
| 264 | +# dbx spec status helpers |
263 | 265 | # --------------------------------------------------------------------------- |
264 | 266 |
|
265 | 267 |
|
| 268 | +def _join_continuations(text: str) -> list[str]: |
| 269 | + """Merge shell line-continuations into single logical lines.""" |
| 270 | + lines: list[str] = [] |
| 271 | + buf = "" |
| 272 | + for line in text.splitlines(): |
| 273 | + if line.rstrip().endswith("\\"): |
| 274 | + buf += line.rstrip()[:-1] + " " |
| 275 | + else: |
| 276 | + lines.append(buf + line) |
| 277 | + buf = "" |
| 278 | + if buf: |
| 279 | + lines.append(buf) |
| 280 | + return lines |
| 281 | + |
| 282 | + |
| 283 | +def _parse_resync_script(script_path: Path) -> dict[str, list[tuple[str, str]]]: |
| 284 | + """Parse resync-specs.sh and return {canonical_name: [(specs_src, driver_dst), ...]}. |
| 285 | +
|
| 286 | + The canonical name is the first label in each case branch (before any ``|``). |
| 287 | + Paths have trailing slashes stripped. |
| 288 | + """ |
| 289 | + try: |
| 290 | + text = script_path.read_text() |
| 291 | + except OSError: |
| 292 | + return {} |
| 293 | + |
| 294 | + spec_map: dict[str, list[tuple[str, str]]] = {} |
| 295 | + logical_lines = _join_continuations(text) |
| 296 | + |
| 297 | + in_for = False |
| 298 | + current_canonical: str | None = None |
| 299 | + current_calls: list[tuple[str, str]] = [] |
| 300 | + |
| 301 | + for line in logical_lines: |
| 302 | + stripped = line.strip() |
| 303 | + |
| 304 | + # Enter the 'for spec in "$@"' loop |
| 305 | + if 'for spec in "$@"' in line: |
| 306 | + in_for = True |
| 307 | + continue |
| 308 | + |
| 309 | + if not in_for: |
| 310 | + continue |
| 311 | + |
| 312 | + # Case label: " auth)" or " bson-binary-vector|bson_binary_vector)" |
| 313 | + # Must end with ) but not be 'case ...' or '*)' |
| 314 | + if ( |
| 315 | + stripped.endswith(")") |
| 316 | + and not stripped.startswith("case ") |
| 317 | + and not stripped.startswith("*") |
| 318 | + and not stripped.startswith("#") |
| 319 | + ): |
| 320 | + labels_str = stripped.rstrip(")") |
| 321 | + current_canonical = labels_str.split("|")[0] |
| 322 | + current_calls = [] |
| 323 | + continue |
| 324 | + |
| 325 | + # End of case block |
| 326 | + if stripped == ";;": |
| 327 | + if current_canonical and current_calls: |
| 328 | + spec_map[current_canonical] = current_calls |
| 329 | + current_canonical = None |
| 330 | + current_calls = [] |
| 331 | + continue |
| 332 | + |
| 333 | + # cpjson call inside a block |
| 334 | + if current_canonical and stripped.startswith("cpjson "): |
| 335 | + parts = stripped.split() |
| 336 | + if len(parts) >= 3: |
| 337 | + src = parts[1].rstrip("/") |
| 338 | + dst = parts[2].rstrip("/") |
| 339 | + current_calls.append((src, dst)) |
| 340 | + |
| 341 | + return spec_map |
| 342 | + |
| 343 | + |
| 344 | +def _get_current_branch(repo_path: Path) -> str: |
| 345 | + result = subprocess.run( |
| 346 | + ["git", "branch", "--show-current"], |
| 347 | + cwd=repo_path, |
| 348 | + capture_output=True, |
| 349 | + text=True, |
| 350 | + check=False, |
| 351 | + ) |
| 352 | + return result.stdout.strip() or "HEAD (detached)" |
| 353 | + |
| 354 | + |
| 355 | +def _find_recent_resync_commits(repo_path: Path, n: int = 5) -> list[str]: |
| 356 | + """Return up to *n* recent commits whose subject mentions 'resync'.""" |
| 357 | + result = subprocess.run( |
| 358 | + [ |
| 359 | + "git", |
| 360 | + "log", |
| 361 | + "--oneline", |
| 362 | + "--max-count=100", |
| 363 | + "--grep=resync", |
| 364 | + "--regexp-ignore-case", |
| 365 | + ], |
| 366 | + cwd=repo_path, |
| 367 | + capture_output=True, |
| 368 | + text=True, |
| 369 | + check=False, |
| 370 | + ) |
| 371 | + lines = [ln.strip() for ln in result.stdout.splitlines() if ln.strip()] |
| 372 | + return lines[:n] |
| 373 | + |
| 374 | + |
| 375 | +def _commit_relative_date(repo_path: Path, commit_sha: str) -> str: |
| 376 | + """Return a human-readable relative date for a commit (e.g. '3 days ago').""" |
| 377 | + result = subprocess.run( |
| 378 | + ["git", "log", "-1", "--format=%ar", commit_sha], |
| 379 | + cwd=repo_path, |
| 380 | + capture_output=True, |
| 381 | + text=True, |
| 382 | + check=False, |
| 383 | + ) |
| 384 | + return result.stdout.strip() or "unknown" |
| 385 | + |
| 386 | + |
| 387 | +def _spec_is_stale( |
| 388 | + specs_source: Path, |
| 389 | + driver_test: Path, |
| 390 | + mappings: list[tuple[str, str]], |
| 391 | +) -> tuple[bool, str]: |
| 392 | + """Check staleness for a spec's cpjson mappings. |
| 393 | +
|
| 394 | + Returns ``(is_stale, reason_string)``. Only JSON files are compared |
| 395 | + (matching what resync-specs.sh copies). |
| 396 | + """ |
| 397 | + for spec_dir, driver_dir in mappings: |
| 398 | + src = specs_source / spec_dir |
| 399 | + dst = driver_test / driver_dir |
| 400 | + |
| 401 | + if not src.exists(): |
| 402 | + continue # specs repo doesn't have this dir; skip |
| 403 | + |
| 404 | + if not dst.exists(): |
| 405 | + return True, f"test dir '{driver_dir}' missing in driver repo" |
| 406 | + |
| 407 | + src_files = {f.relative_to(src): f for f in src.rglob("*.json")} |
| 408 | + dst_files = {f.relative_to(dst): f for f in dst.rglob("*.json")} |
| 409 | + |
| 410 | + new_in_src = src_files.keys() - dst_files.keys() |
| 411 | + if new_in_src: |
| 412 | + return True, f"{len(new_in_src)} new file(s) in specs not in driver" |
| 413 | + |
| 414 | + for rel, src_file in src_files.items(): |
| 415 | + if rel in dst_files and not filecmp.cmp( |
| 416 | + str(src_file), str(dst_files[rel]), shallow=False |
| 417 | + ): |
| 418 | + return True, f"content differs: {rel}" |
| 419 | + |
| 420 | + return False, "" |
| 421 | + |
| 422 | + |
| 423 | +# --------------------------------------------------------------------------- |
| 424 | +# dbx spec status |
| 425 | +# --------------------------------------------------------------------------- |
| 426 | + |
| 427 | + |
| 428 | +@app.command("status") |
| 429 | +def spec_status( |
| 430 | + ctx: typer.Context, |
| 431 | + repo_name: str = typer.Option( |
| 432 | + "mongo-python-driver", |
| 433 | + "--repo", |
| 434 | + "-r", |
| 435 | + help="Driver repository to inspect", |
| 436 | + ), |
| 437 | + specs_dir: str = typer.Option( |
| 438 | + None, |
| 439 | + "--specs-dir", |
| 440 | + help="Path to the MongoDB specifications repo (overrides auto-detection)", |
| 441 | + ), |
| 442 | +): |
| 443 | + """Show which specs are out of date and suggest sync commands. |
| 444 | +
|
| 445 | + Compares JSON test files in the driver repo against the specifications |
| 446 | + repository and lists any specs whose files differ. Also checks whether |
| 447 | + the current branch looks like a spec-resync branch and whether a recent |
| 448 | + resync commit exists. |
| 449 | +
|
| 450 | + Usage:: |
| 451 | +
|
| 452 | + dbx spec status |
| 453 | + dbx spec status -r django-mongodb-backend |
| 454 | + dbx spec status --specs-dir ~/my-specs |
| 455 | + """ |
| 456 | + verbose = ctx.obj.get("verbose", False) if ctx.obj else False |
| 457 | + config = get_config() |
| 458 | + base_dir = get_base_dir(config) |
| 459 | + |
| 460 | + driver_repo = _get_driver_repo(repo_name, base_dir, config) |
| 461 | + driver_path: Path = driver_repo["path"] |
| 462 | + driver_test = driver_path / "test" |
| 463 | + |
| 464 | + if specs_dir: |
| 465 | + mdb_specs = Path(specs_dir).expanduser().resolve() |
| 466 | + else: |
| 467 | + mdb_specs = _find_specs_dir(config, base_dir) |
| 468 | + if not mdb_specs: |
| 469 | + typer.echo( |
| 470 | + "❌ Error: Could not find the 'specifications' repository", err=True |
| 471 | + ) |
| 472 | + typer.echo("\nClone it with: dbx clone specifications") |
| 473 | + typer.echo("Or specify the path with: --specs-dir <path>") |
| 474 | + raise typer.Exit(1) |
| 475 | + |
| 476 | + if not mdb_specs.exists(): |
| 477 | + typer.echo( |
| 478 | + f"❌ Error: Specifications directory not found: {mdb_specs}", err=True |
| 479 | + ) |
| 480 | + raise typer.Exit(1) |
| 481 | + |
| 482 | + specs_source = mdb_specs / "source" |
| 483 | + if not specs_source.exists(): |
| 484 | + specs_source = mdb_specs # some layouts skip the 'source' subdir |
| 485 | + |
| 486 | + script = driver_path / ".evergreen" / "resync-specs.sh" |
| 487 | + if not script.exists(): |
| 488 | + typer.echo(f"❌ Error: resync-specs.sh not found at {script}", err=True) |
| 489 | + raise typer.Exit(1) |
| 490 | + |
| 491 | + # --- Header ------------------------------------------------------------ # |
| 492 | + branch = _get_current_branch(driver_path) |
| 493 | + branch_looks_spec = bool(re.search(r"resync|spec", branch, re.IGNORECASE)) |
| 494 | + branch_icon = "✓" if branch_looks_spec else "⚠" |
| 495 | + typer.echo(f"\n📊 Spec Status — {repo_name}\n") |
| 496 | + typer.echo(f" 🌿 Branch: {branch} {branch_icon}") |
| 497 | + if not branch_looks_spec: |
| 498 | + typer.echo( |
| 499 | + " ⚠ Branch name does not look like a spec-resync branch.", |
| 500 | + err=True, |
| 501 | + ) |
| 502 | + |
| 503 | + recent = _find_recent_resync_commits(driver_path) |
| 504 | + if recent: |
| 505 | + sha = recent[0].split()[0] |
| 506 | + age = _commit_relative_date(driver_path, sha) |
| 507 | + typer.echo(f" 🕐 Last resync commit: {recent[0]} ({age})") |
| 508 | + if verbose and len(recent) > 1: |
| 509 | + for c in recent[1:]: |
| 510 | + typer.echo(f" also: {c}") |
| 511 | + else: |
| 512 | + typer.echo(" ⚠ No resync commit found on this branch.", err=True) |
| 513 | + |
| 514 | + # --- Parse spec map ---------------------------------------------------- # |
| 515 | + spec_map = _parse_resync_script(script) |
| 516 | + if not spec_map: |
| 517 | + typer.echo("\n⚠ Could not parse resync-specs.sh — no spec mappings found.") |
| 518 | + raise typer.Exit(1) |
| 519 | + |
| 520 | + typer.echo(f"\n Checking {len(spec_map)} spec(s) against {specs_source}...\n") |
| 521 | + |
| 522 | + stale: list[tuple[str, str]] = [] # (spec_name, reason) |
| 523 | + up_to_date: list[str] = [] |
| 524 | + skipped: list[str] = [] |
| 525 | + |
| 526 | + for spec_name, mappings in sorted(spec_map.items()): |
| 527 | + # Check that at least one source dir actually exists; skip if not |
| 528 | + any_src_exists = any((specs_source / src).exists() for src, _ in mappings) |
| 529 | + if not any_src_exists: |
| 530 | + skipped.append(spec_name) |
| 531 | + if verbose: |
| 532 | + typer.echo(f" [skip] {spec_name} — source dir not found in specs repo") |
| 533 | + continue |
| 534 | + |
| 535 | + is_stale, reason = _spec_is_stale(specs_source, driver_test, mappings) |
| 536 | + if is_stale: |
| 537 | + stale.append((spec_name, reason)) |
| 538 | + else: |
| 539 | + up_to_date.append(spec_name) |
| 540 | + |
| 541 | + # --- Output ------------------------------------------------------------ # |
| 542 | + if stale: |
| 543 | + typer.echo(f" ❌ Stale ({len(stale)}) — suggest syncing:\n") |
| 544 | + for i, (name, reason) in enumerate(stale): |
| 545 | + is_last = i == len(stale) - 1 |
| 546 | + prefix = " └──" if is_last else " ├──" |
| 547 | + reason_str = f" [{reason}]" if verbose else "" |
| 548 | + typer.echo(f"{prefix} dbx spec sync {name}{reason_str}") |
| 549 | + else: |
| 550 | + typer.echo(" ✅ All checked specs are up to date.") |
| 551 | + |
| 552 | + if up_to_date: |
| 553 | + typer.echo(f"\n ✅ Up to date ({len(up_to_date)}): " + ", ".join(up_to_date)) |
| 554 | + |
| 555 | + if skipped: |
| 556 | + typer.echo( |
| 557 | + f"\n ⚠ Skipped ({len(skipped)}, source dir not found): " |
| 558 | + + ", ".join(skipped) |
| 559 | + ) |
| 560 | + |
| 561 | + # --- Patches ----------------------------------------------------------- # |
| 562 | + _show_patch_summary(driver_repo, verbose) |
| 563 | + |
| 564 | + # --- Suggested command block ------------------------------------------- # |
| 565 | + if stale: |
| 566 | + spec_names = " ".join(name for name, _ in stale) |
| 567 | + typer.echo("\n 💡 To sync all stale specs at once:") |
| 568 | + typer.echo(f" dbx spec sync {spec_names}\n") |
| 569 | + |
| 570 | + |
266 | 571 | @app.command("list") |
267 | 572 | def spec_list( |
268 | 573 | ctx: typer.Context, |
|
0 commit comments