Skip to content

Commit e35c559

Browse files
aclark4lifeCopilot
andcommitted
Add dbx spec status command
Compares JSON spec files in the driver repo against the local specifications repository to identify which specs are out of date, then suggests the exact 'dbx spec sync' commands to run. - Parses resync-specs.sh to build the spec→file mappings automatically - Handles multi-line cpjson calls (backslash continuations) - Compares JSON files with filecmp for accurate content-level staleness - Shows current branch with ✓/⚠ based on whether it looks like a resync branch - Reports most recent resync commit and its relative age - Warns when no resync commit is found on the current branch - Lists active patches summary at the end - Suggests a combined 'dbx spec sync <all stale>' command in the footer - 14 new tests: helper unit tests + CLI integration tests - Docs updated in spec-sync.rst (new section + comparison table row) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e987f30 commit e35c559

3 files changed

Lines changed: 723 additions & 2 deletions

File tree

docs/features/spec-sync.rst

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ How It Improves on the Manual Workflow
5757
+--------------------------------+-----------------------------------------------+----------------------------------------------+
5858
| Remove a resolved patch | ``rm .evergreen/spec-patch/PYTHON-XXXX.patch``| ``dbx spec patch remove PYTHON-XXXX`` |
5959
+--------------------------------+-----------------------------------------------+----------------------------------------------+
60+
| Check which specs need syncing | Manually diff repo directories | ``dbx spec status`` |
61+
+--------------------------------+-----------------------------------------------+----------------------------------------------+
6062
| Apply all patches | ``git apply -R --allow-empty ...`` | ``dbx spec patch apply`` |
6163
+--------------------------------+-----------------------------------------------+----------------------------------------------+
6264

@@ -149,7 +151,65 @@ Lists available spec directories from the local ``specifications`` repository.
149151
150152
Use the spec names from this output as arguments to ``dbx spec sync``.
151153

152-
dbx spec patch list
154+
dbx spec status
155+
~~~~~~~~~~~~~~~
156+
157+
Compares JSON spec files in the driver repo against the local ``specifications`` repository and reports which specs are out of date, then suggests the exact ``dbx spec sync`` commands to run. Also checks whether the current branch looks like a spec-resync branch and whether a recent resync commit exists.
158+
159+
.. code-block:: bash
160+
161+
# Check status for the default driver repo (mongo-python-driver)
162+
dbx spec status
163+
164+
# Check a different driver repo
165+
dbx spec status -r django-mongodb-backend
166+
167+
# Use a custom path for the specifications repo
168+
dbx spec status --specs-dir ~/my-specs
169+
170+
**Example output:**
171+
172+
.. code-block:: text
173+
174+
📊 Spec Status — mongo-python-driver
175+
176+
🌿 Branch: spec-resync-05-18-2026 ✓
177+
🕐 Last resync commit: 36dffed resyncing specs 05-18-2026 (3 days ago)
178+
179+
Checking 35 spec(s) against ~/Developer/mongodb/specifications/source...
180+
181+
❌ Stale (3) — suggest syncing:
182+
183+
├── dbx spec sync crud
184+
├── dbx spec sync sessions
185+
└── dbx spec sync transactions
186+
187+
✅ Up to date (32): auth, bson-binary-vector, bson-corpus, ...
188+
189+
📋 2 active patch(es) in mongo-python-driver:
190+
• PYTHON-1234 (3 file(s))
191+
• PYTHON-5678 (1 file(s))
192+
193+
Run 'dbx spec patch apply' to apply them.
194+
195+
💡 To sync all stale specs at once:
196+
dbx spec sync crud sessions transactions
197+
198+
**Notes:**
199+
200+
- Only ``.json`` files are compared (YAML files are excluded by ``resync-specs.sh`` by default).
201+
- Run ``make`` in ``specifications/source`` first if you want to ensure the JSON files are freshly generated from YAML.
202+
- A ```` next to the branch name means the branch does not contain "resync" or "spec" in its name.
203+
- If no resync commit is found on the branch, a warning is shown — this may indicate you're on the wrong branch.
204+
205+
**Options:**
206+
207+
.. code-block:: text
208+
209+
-r, --repo Driver repository to inspect [default: mongo-python-driver]
210+
--specs-dir Path to the MongoDB specifications repo (overrides auto-detection)
211+
212+
153213
~~~~~~~~~~~~~~~~~~~
154214

155215
Lists all active patch files and the test files each one affects. Add ``-v`` to see the individual filenames.

src/dbx_python_cli/commands/spec.py

Lines changed: 306 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Spec command for managing spec syncs with the MongoDB specifications repository."""
22

3+
import filecmp
34
import os
5+
import re
46
import subprocess
57
from pathlib import Path
68

@@ -259,10 +261,313 @@ def spec_sync(
259261

260262

261263
# ---------------------------------------------------------------------------
262-
# dbx spec list
264+
# dbx spec status helpers
263265
# ---------------------------------------------------------------------------
264266

265267

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+
266571
@app.command("list")
267572
def spec_list(
268573
ctx: typer.Context,

0 commit comments

Comments
 (0)