diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 854bacf..b314bf8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,10 +109,30 @@ jobs: name: python-dist path: back/dist/ + validate-python-package: + name: Validate PyPI package (${{ matrix.python-version }}) + needs: build-python + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - uses: actions/download-artifact@v4 + with: + name: python-dist + path: dist + - name: Install the built artifact in empty virtual environments + run: python back/scripts/validate_distribution.py --dist dist + # ── Publish to PyPI ──────────────────────────────────────────────────────── publish-pypi: name: Publish to PyPI - needs: build-python + needs: [build-python, validate-python-package] runs-on: ubuntu-latest environment: name: pypi diff --git a/back/README.md b/back/README.md index c188564..40539ed 100644 --- a/back/README.md +++ b/back/README.md @@ -1,4 +1,14 @@ -# MockSQL — Backend +# MockSQL + +MockSQL generates SQL unit-test fixtures with an LLM, runs them locally on +DuckDB, evaluates their quality, and saves replayable tests for CI. Install the +CLI with `pip install mocksql`; install `mocksql[bigquery]`, +`mocksql[snowflake]`, or `mocksql[all]` when a source connector is needed. + +Generated tests never execute against the source warehouse. See the +[project README](../README.md) for user setup and connector guidance. + +## Development > Pour l'installation, la configuration GCP et le CLI, voir le [README racine](../README.md). @@ -6,7 +16,6 @@ --- -## Développement local ```bash python -m venv .venv @@ -40,22 +49,14 @@ poetry run mypy build_query/ app/ ## Packaging -MockSQL produit deux wheels : - -| Wheel | Contenu | -|-------|---------| -| `mocksql-*.whl` | CLI + LangGraph core (sans UI) | -| `mocksql_ui-*.whl` | Serveur web + assets React bundlés | - ```bash -make build-cli # CLI uniquement -make build-ui # CLI + UI (Node.js 18+ requis pour le build React) +poetry build --output dist ``` -Les wheels sont générés dans `dist/`. +This produces the `mocksql` wheel and source distribution in `dist/`. --- -## Licence +## License -Propriétaire — © 2025 Adel Skhiri. Contact : [skhiriadel92@gmail.com](mailto:skhiriadel92@gmail.com) +MockSQL is released under the [MIT License](../LICENSE). diff --git a/back/poetry.lock b/back/poetry.lock index 29a1686..1c866f7 100644 --- a/back/poetry.lock +++ b/back/poetry.lock @@ -3873,4 +3873,4 @@ trino = ["trino"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.14" -content-hash = "4cd28bc782e17d2f869a9fa233b0a6dd7d484f0395408430e242f0a94c6edd23" +content-hash = "42886f2f5cbaee1a99523c438f50957c329a55406a4508916d16f837b89375d1" diff --git a/back/pyproject.toml b/back/pyproject.toml index 2285eae..688db96 100644 --- a/back/pyproject.toml +++ b/back/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "mocksql" -version = "0.2.0" -description = "SQL unit test data generator for Analytics Engineers — SQLGlot extracts constraints, LLM generates fixtures, DuckDB validates with automatic retry on empty results" +version = "0.2.1" +description = "Generate, evaluate, and replay SQL unit tests locally with LLM-created fixtures and DuckDB." authors = ["Adel Skhiri "] license = "MIT" readme = "README.md" @@ -33,7 +33,9 @@ include = [ [tool.poetry.dependencies] python = ">=3.11,<3.14" -typer = {extras = ["all"], version = "^0.26.7"} +# Typer 0.26 no longer exposes an ``all`` extra. Depending on it makes pip +# emit a warning during every installation. +typer = "^0.26.7" uvicorn = "^0.49.0" langchain = "^1.3.10" langchain-classic = "1.0.8" diff --git a/back/scripts/validate_distribution.py b/back/scripts/validate_distribution.py new file mode 100644 index 0000000..e5ec99c --- /dev/null +++ b/back/scripts/validate_distribution.py @@ -0,0 +1,198 @@ +"""Install and smoke-test a built MockSQL distribution in isolated venvs. + +This deliberately uses neither the source checkout nor PYTHONPATH. It is used +by GitHub Actions after ``poetry build`` and can also validate a release +candidate locally on Windows. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import tempfile +import tarfile +import zipfile +from email.parser import BytesParser +from pathlib import Path + + +def run(command: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.pop("PYTHONPATH", None) + return subprocess.run( # noqa: S603 -- commands are assembled from CI-controlled paths + command, cwd=cwd, env=env, text=True, check=True + ) + + +def venv_python(root: Path, name: str) -> Path: + venv = root / name + run([sys.executable, "-m", "venv", str(venv)], root) + return venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + + +def install(python: Path, wheel: Path, extra: str = "") -> None: + run([str(python), "-m", "pip", "install", "--upgrade", "pip"], wheel.parent) + requirement = f"mocksql{extra} @ {wheel.as_uri()}" + run( + [ + str(python), + "-m", + "pip", + "install", + requirement, + ], + wheel.parent, + ) + run([str(python), "-m", "pip", "check"], wheel.parent) + + +def candidate_wheel(dist: Path) -> tuple[Path, str]: + wheels = list(dist.glob("mocksql-*.whl")) + if not wheels: + raise RuntimeError("no mocksql wheel found") + wheel = max(wheels, key=lambda path: path.stat().st_mtime) + with zipfile.ZipFile(wheel) as archive: + metadata_name = next( + name for name in archive.namelist() if name.endswith(".dist-info/METADATA") + ) + metadata = BytesParser().parsebytes(archive.read(metadata_name)) + if metadata["Name"] != "mocksql" or not metadata["Version"]: + raise RuntimeError("wheel has invalid mocksql metadata") + return wheel, metadata["Version"] + + +def assert_wheel_assets(dist: Path, wheel: Path, version: str) -> None: + with zipfile.ZipFile(wheel) as archive: + names = set(archive.namelist()) + required = { + "cli/main.py", + "build_query/query_chain.py", + "storage/config.py", + "static/index.html", + "static/manifest.json", + } + missing = required - names + if missing: + raise RuntimeError(f"wheel is missing required files: {sorted(missing)}") + if not any(name.startswith("static/assets/") for name in names): + raise RuntimeError("wheel is missing compiled frontend assets") + sdists = list(dist.glob(f"mocksql-{version}.tar.gz")) + if not sdists: + raise RuntimeError("sdist is missing") + with tarfile.open(sdists[0]) as archive: + sdist_names = set(archive.getnames()) + sdist_required = { + "pyproject.toml", + "README.md", + "cli/main.py", + "build_query/query_chain.py", + "static/index.html", + } + if not all( + any(name.endswith(required) for name in sdist_names) + for required in sdist_required + ): + raise RuntimeError("sdist is missing package sources") + + +def mocksql_command(python: Path) -> list[str]: + executable = python.parent / ("mocksql.exe" if os.name == "nt" else "mocksql") + return [str(executable)] + + +def assert_installed_candidate( + python: Path, wheel: Path, version: str, cwd: Path +) -> None: + probe = ( + "import importlib.metadata as m, json; " + "d=m.distribution('mocksql'); " + "print(json.dumps({'version': d.version, 'direct_url': " + "d.read_text('direct_url.json')}))" + ) + result = run([str(python), "-c", probe], cwd) + import json + + installed = json.loads(result.stdout) + if installed["version"] != version or wheel.as_uri() not in installed["direct_url"]: + raise RuntimeError( + "installed mocksql metadata does not point to the candidate wheel" + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--dist", type=Path, required=True) + args = parser.parse_args() + dist = args.dist.resolve() + wheel, version = candidate_wheel(dist) + assert_wheel_assets(dist, wheel, version) + + with tempfile.TemporaryDirectory(prefix="mocksql-dist-") as temp: + root = Path(temp) + project = root / "project" + (project / "models").mkdir(parents=True) + + base = venv_python(root, "base") + install(base, wheel) + assert_installed_candidate(base, wheel, version, project) + run(mocksql_command(base) + ["--help"], project) + run( + mocksql_command(base) + + [ + "init", + "--path", + str(project), + "--models-path", + "./models", + "--dialect", + "duckdb", + "--llm-provider", + "openai", + "--test-dataset", + "test_dataset", + "--non-interactive", + ], + project, + ) + if not (project / "mocksql.yml").is_file(): + raise RuntimeError("mocksql init did not create mocksql.yml") + + for extra, modules in { + "[bigquery]": ["google.cloud.bigquery"], + "[snowflake]": ["snowflake.connector"], + "[all]": ["google.cloud.bigquery", "snowflake.connector", "trino"], + }.items(): + python = venv_python(root, extra[1:-1]) + install(python, wheel, extra) + assert_installed_candidate(python, wheel, version, project) + for module in modules: + run([str(python), "-c", f"import {module}"], project) + + missing_connectors = { + "bigquery": "from utils.optional_deps import import_bigquery; import_bigquery()", + "snowflake": "from utils.snowflake_connector import _import_snowflake; _import_snowflake()", + "trino": "from utils.optional_deps import import_trino; import_trino()", + } + for connector, probe in missing_connectors.items(): + missing = subprocess.run( # noqa: S603 -- installed venv and fixed probe only + [str(base), "-c", probe], + cwd=project, + text=True, + capture_output=True, + env={ + key: value + for key, value in os.environ.items() + if key != "PYTHONPATH" + }, + ) + expected = f"pip install mocksql[{connector}]" + if missing.returncode == 0 or expected not in missing.stderr: + raise RuntimeError( + "base install did not fail with the expected message: " + expected + ) + + +if __name__ == "__main__": + main()