Ctrl K
ComunidadGuías…

scripts/compatibility_matrix.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
compatibility_matrix.py

Deduces SDK <-> Server compatibility using the relay model:

Each SDK version is compatible with the server versions that existed
during its active lifetime — from the server current when it was released,
up to (but not including) the server version that the next SDK release
was built against.

USAGE:
    python compatibility_matrix.py \
        --core  C:/Users/franc/PycharmProjects/projectdavid-core \
        --sdk   C:/Users/franc/PycharmProjects/entitites_sdk \
        --out   compatibility.json \
        --md    COMPATIBILITY.md

OUTPUT:
    compatibility.json   — machine-readable matrix
    COMPATIBILITY.md     — human-readable two-column table
"""

import argparse
import json
import re
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path

# ─── DATA STRUCTURES ────────────────────────────────────────────────────────


@dataclass
class ReleasePin:
    tag: str
    timestamp: datetime
    common_pin: str | None = None
    orm_pin: str | None = None
    raw_deps: list[str] = field(default_factory=list)


# ─── GIT HELPERS ────────────────────────────────────────────────────────────


def git(repo: Path, *args: str) -> str:
    result = subprocess.run(
        ["git", "-C", str(repo), *args],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        raise RuntimeError(f"git error in {repo}: {result.stderr.strip()}")
    return result.stdout.strip()


def get_tags(repo: Path) -> list[str]:
    raw = git(repo, "tag", "--sort=version:refname")
    return [t for t in raw.splitlines() if t.strip()]


def get_tag_timestamp(repo: Path, tag: str) -> datetime:
    raw = git(repo, "log", "-1", "--format=%ct", tag)
    try:
        return datetime.fromtimestamp(int(raw), tz=timezone.utc)
    except (ValueError, OSError):
        return datetime.min.replace(tzinfo=timezone.utc)


def get_file_at_tag(repo: Path, tag: str, filepath: str) -> str | None:
    try:
        return git(repo, "show", f"{tag}:{filepath}")
    except RuntimeError:
        return None


PYPROJECT_CANDIDATES = [
    "pyproject.toml",
    "src/api/entities_api/pyproject.toml",
    "src/projectdavid/pyproject.toml",
    "src/pyproject.toml",
    "entities_api/pyproject.toml",
]


def get_pyproject_at_tag(repo: Path, tag: str) -> str | None:
    for candidate in PYPROJECT_CANDIDATES:
        content = get_file_at_tag(repo, tag, candidate)
        if content:
            return content
    return None


# ─── PYPROJECT PARSING ──────────────────────────────────────────────────────


def extract_pins(pyproject_content: str) -> tuple[str | None, str | None, list[str]]:
    common_pin = None
    orm_pin = None
    raw_deps = []

    dep_block_match = re.search(
        r"dependencies\s*=\s*\[(.*?)\]",
        pyproject_content,
        re.DOTALL,
    )
    if not dep_block_match:
        return None, None, []

    block = dep_block_match.group(1)
    deps = re.findall(r'"([^"]+)"', block)
    raw_deps = deps

    for dep in deps:
        normalized = dep.replace("_", "-").lower()
        if normalized.startswith("projectdavid-common"):
            common_pin = dep
        elif normalized.startswith("projectdavid-orm"):
            orm_pin = dep

    return common_pin, orm_pin, raw_deps


# ─── RELEASE EXTRACTION ─────────────────────────────────────────────────────


def extract_releases(repo: Path, label: str) -> list[ReleasePin]:
    tags = get_tags(repo)
    releases = []
    print(f"\n[{label}] Found {len(tags)} tags — extracting...")

    for tag in tags:
        content = get_pyproject_at_tag(repo, tag)
        ts = get_tag_timestamp(repo, tag)

        if content:
            common_pin, orm_pin, raw_deps = extract_pins(content)
        else:
            common_pin, orm_pin, raw_deps = None, None, []

        releases.append(
            ReleasePin(
                tag=tag,
                timestamp=ts,
                common_pin=common_pin,
                orm_pin=orm_pin,
                raw_deps=raw_deps,
            )
        )

    print(f"  [{label}] {len(releases)} releases parsed")
    return releases


# ─── RELAY MODEL ────────────────────────────────────────────────────────────


def version_sort_key(release: ReleasePin) -> tuple[int, ...]:
    """Sort by version number — handles retroactive tagging correctly."""
    tag = release.tag.lstrip("v")
    parts = []
    for p in re.split(r"[\.\-]", tag):
        try:
            parts.append(int(p))
        except ValueError:
            parts.append(0)
    return tuple(parts)


def nearest_server_at_or_before(
    ts: datetime,
    server_timeline: list[ReleasePin],
) -> ReleasePin | None:
    """Return the most recent server release at or before the given timestamp."""
    result = None
    for s in server_timeline:
        if s.timestamp <= ts:
            result = s
    return result


def server_index(tag: str, server_timeline: list[ReleasePin]) -> int:
    for i, s in enumerate(server_timeline):
        if s.tag == tag:
            return i
    return -1


def build_matrix(
    sdk_releases: list[ReleasePin],
    server_releases: list[ReleasePin],
) -> list[dict]:
    """
    Relay model: each SDK version is compatible with server versions
    that existed during its active lifetime — from the server current
    when it was released, up to the server that the next SDK targets.
    """
    matrix = []

    # Exclude pre-release server tags (alpha/beta/rc) from the compatibility timeline
    server_timeline = sorted(
        [
            s
            for s in server_releases
            if not any(x in s.tag.lower() for x in ["alpha", "beta", "rc"])
        ],
        key=version_sort_key,
    )
    sdk_sorted = sorted(sdk_releases, key=version_sort_key)

    for i, sdk in enumerate(sdk_sorted):
        lower = nearest_server_at_or_before(sdk.timestamp, server_timeline)

        if i + 1 < len(sdk_sorted):
            next_sdk = sdk_sorted[i + 1]
            upper = nearest_server_at_or_before(next_sdk.timestamp, server_timeline)
        else:
            upper = server_timeline[-1] if server_timeline else None

        if not lower:
            compatible_versions = []
            compatible_range = "unknown"
        elif lower.tag == (upper.tag if upper else None):
            compatible_versions = [lower.tag]
            compatible_range = lower.tag
        else:
            lo_idx = server_index(lower.tag, server_timeline)
            hi_idx = server_index(
                upper.tag if upper else server_timeline[-1].tag, server_timeline
            )
            if lo_idx > hi_idx:
                lo_idx, hi_idx = hi_idx, lo_idx
            compatible_versions = [s.tag for s in server_timeline[lo_idx : hi_idx + 1]]
            compatible_range = _summarize_range(compatible_versions) or "unknown"

        # Skip pre-release SDK tags from output
        if sdk.tag.startswith("test-") or "alpha" in sdk.tag or "beta" in sdk.tag:
            continue

        matrix.append(
            {
                "sdk_version": sdk.tag,
                "compatible_server_versions": compatible_versions,
                "compatible_server_range": compatible_range,
            }
        )

    # Restore original tag order
    tag_order = {s.tag: i for i, s in enumerate(sdk_releases)}
    matrix.sort(key=lambda r: tag_order.get(r["sdk_version"], 9999))

    return matrix


def _summarize_range(versions: list[str]) -> str | None:
    if not versions:
        return None
    if len(versions) == 1:
        return versions[0]
    return f">={versions[0]},<={versions[-1]}"


# ─── OUTPUT ──────────────────────────────────────────────────────────────────


def write_json(matrix: list[dict], path: Path) -> None:
    path.write_text(json.dumps(matrix, indent=2))
    print(f"\n✅ JSON written → {path}")


def write_markdown(matrix: list[dict], path: Path) -> None:
    lines = [
        "# Project David — SDK ↔ Server Compatibility Matrix",
        "",
        "Auto-generated by `compatibility_matrix.py`.",
        "Compatibility deduced from chronological co-release history.",
        "",
        "| SDK Version | Compatible Server Versions |",
        "|-------------|---------------------------|",
    ]

    for row in matrix:
        servers = row["compatible_server_range"] or "unknown"
        lines.append(f"| `{row['sdk_version']}` | `{servers}` |")

    lines += [
        "",
        "## Notes",
        "",
        "- Compatibility is deduced from chronological co-release history, not tested.",
        "- Each SDK version is compatible with server versions that existed during its active lifetime.",
        "- Rows marked `unknown` are early tags that predate parseable dependency metadata.",
        "- When in doubt, always use the latest SDK with the latest server.",
        "",
    ]

    path.write_text("\n".join(lines))
    print(f"✅ Markdown written → {path}")


# ─── CLI ─────────────────────────────────────────────────────────────────────


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Build SDK <-> Server compatibility matrix"
    )
    parser.add_argument("--core", required=True, help="Path to projectdavid-core repo")
    parser.add_argument("--sdk", required=True, help="Path to projectdavid SDK repo")
    parser.add_argument("--out", default="compatibility.json", help="Output JSON path")
    parser.add_argument("--md", default="COMPATIBILITY.md", help="Output markdown path")
    args = parser.parse_args()

    core_path = Path(args.core)
    sdk_path = Path(args.sdk)
    out_path = Path(args.out)
    md_path = Path(args.md)

    for p, label in [(core_path, "--core"), (sdk_path, "--sdk")]:
        if not (p / ".git").exists():
            print(f"ERROR: {label} path '{p}' is not a git repo")
            sys.exit(1)

    print("=" * 60)
    print("Project David — Compatibility Matrix Builder")
    print("=" * 60)

    server_releases = extract_releases(core_path, "SERVER")
    sdk_releases = extract_releases(sdk_path, "SDK")

    print(
        f"\n[MATRIX] Building: {len(sdk_releases)} SDK × {len(server_releases)} server releases"
    )

    matrix = build_matrix(sdk_releases, server_releases)

    write_json(matrix, out_path)
    write_markdown(matrix, md_path)

    unmatched = [r for r in matrix if not r["compatible_server_versions"]]
    print(
        f"\nSummary: {len(matrix)} SDK releases, {len(unmatched)} with no server match"
    )


if __name__ == "__main__":
    main()