From b6e6853bffb556080644f05b59ec13cf71b61b5f Mon Sep 17 00:00:00 2001 From: Joel Natividad <1980690+jqnatividad@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:32:08 -0400 Subject: [PATCH] build: qsv-client >= 0.4.0 qsv-client 0.4.0 requires qsv 25.0.0+ (already in the image) and checks `qsv --capabilities` before a client's first run. - QsvVersionError is now a QsvError subclass, so drop it from _QSV_FAILURES and fix the comment. - The fake qsv in the profiling tests answers --capabilities like qsv 25.0.0; the qsvlite test no longer relies on the removed --version/--list fallback. - New test: a qsv older than 25.0.0 makes a profiling pass return None instead of raising. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- .../data_layer/qsv_profiling.py | 7 +-- tests/unit/test_qsv_profiling.py | 57 ++++++++++++++----- uv.lock | 8 +-- 4 files changed, 51 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 31c341f..acab7cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ dependencies = [ "httpx>=0.26.0", # qsv runner (timeouts, typed errors) for the onboarding profiling passes - "qsv-client>=0.3.0", + "qsv-client>=0.4.0", # Vector Search (Pinecone for CKAN semantic search) # Note: Use the new `pinecone` package (v5+), NOT the legacy diff --git a/src/data_concierge/data_layer/qsv_profiling.py b/src/data_concierge/data_layer/qsv_profiling.py index 7d5a55f..b94d918 100644 --- a/src/data_concierge/data_layer/qsv_profiling.py +++ b/src/data_concierge/data_layer/qsv_profiling.py @@ -45,7 +45,6 @@ QsvError, QsvNotFound, QsvTimeout, - QsvVersionError, ) from data_concierge.data_layer.storage import storage @@ -80,10 +79,10 @@ async def download_csv(url: str, dest: Path) -> int: "count": 120.0, } -# A failed run, a missing or too-old binary, or output that does not parse. QsvNotFound and -# QsvVersionError are not QsvError subclasses, and the client raises QsvNotFound from its +# A failed run, a missing or too-old binary, or output that does not parse. A too-old qsv +# (QsvVersionError) is a QsvError; QsvNotFound is not, and the client raises it from its # constructor. -_QSV_FAILURES = (QsvError, QsvNotFound, QsvVersionError, ValueError) +_QSV_FAILURES = (QsvError, QsvNotFound, ValueError) # Binaries whose missing describegpt has already been reported, so a portal run warns once. _describegpt_checked: set[str] = set() diff --git a/tests/unit/test_qsv_profiling.py b/tests/unit/test_qsv_profiling.py index e2eb5e2..129a29e 100644 --- a/tests/unit/test_qsv_profiling.py +++ b/tests/unit/test_qsv_profiling.py @@ -23,11 +23,34 @@ posix_only = pytest.mark.skipif(sys.platform == "win32", reason="fake qsv is a POSIX script") -def _fake_qsv(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, body: str) -> Path: - """Put a Python script named ``qsv`` first on PATH, ahead of any real qsv.""" +QSV_CAPABILITIES = { + "binary": "qsv", + "version": "25.0.0", + "commands": ["count", "describegpt", "frequency", "stats"], +} + + +def _fake_qsv( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + body: str, + capabilities: dict | None = QSV_CAPABILITIES, +) -> Path: + """Put a Python script named ``qsv`` first on PATH, ahead of any real qsv. + + It answers ``--capabilities`` with ``capabilities`` (qsv-client checks that before the + first run); pass ``None`` to leave it to ``body``, like a qsv older than 25.0.0. + """ fake = tmp_path / "bin" / "qsv" fake.parent.mkdir(exist_ok=True) - fake.write_text(f"#!{sys.executable}\nimport json, os, sys, time\n{body}") + caps = ( + "" + if capabilities is None + else f"if sys.argv[1:] == ['--capabilities']:\n" + f" print({json.dumps(capabilities)!r})\n" + f" sys.exit(0)\n" + ) + fake.write_text(f"#!{sys.executable}\nimport json, os, sys, time\n{caps}{body}") fake.chmod(0o755) monkeypatch.setenv("PATH", f"{fake.parent}:{os.environ['PATH']}") monkeypatch.delenv("QSV_BIN", raising=False) @@ -160,16 +183,8 @@ async def test_describegpt_failure_on_a_binary_without_it_says_so(tmp_path, monk _fake_qsv( tmp_path, monkeypatch, - "arg = sys.argv[1]\n" - "if arg == '--version':\n" - " print('qsvlite 24.0.0-standard-16-16; (aarch64-apple-darwin compiled with Rust 1.99)')\n" - "elif arg == '--list':\n" - " print('Installed commands (2):')\n" - " print(' count Count records')\n" - " print(' stats Infer data types')\n" - "else:\n" - " sys.stderr.write('Invalid arguments.\\n')\n" - " sys.exit(1 if arg == '--capabilities' else 2)\n", + "sys.stderr.write('Invalid arguments.\\n')\nsys.exit(2)\n", + capabilities={"binary": "qsvlite", "version": "25.0.0", "commands": ["count", "stats"]}, ) out = tmp_path / "qsv_dict.json" @@ -177,5 +192,19 @@ async def test_describegpt_failure_on_a_binary_without_it_says_so(tmp_path, monk printed = capsys.readouterr().out assert "has no describegpt" in printed - assert "qsvlite 24.0.0" in printed + assert "qsvlite 25.0.0" in printed assert not out.exists() + + +@posix_only +async def test_too_old_qsv_returns_none_instead_of_raising(tmp_path, monkeypatch, capsys): + # qsv before 25.0.0 rejects --capabilities, and qsv-client refuses to run it. + _fake_qsv( + tmp_path, + monkeypatch, + "sys.stderr.write(\"Unknown flag: '--capabilities'\\n\")\nsys.exit(1)\n", + capabilities=None, + ) + + assert await run_qsv_count(tmp_path / "data.csv") is None + assert "QsvVersionError" in capsys.readouterr().out diff --git a/uv.lock b/uv.lock index 1678767..4498385 100644 --- a/uv.lock +++ b/uv.lock @@ -519,7 +519,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "python-multipart", specifier = ">=0.0.6" }, { name = "pyyaml", specifier = ">=6.0.0" }, - { name = "qsv-client", specifier = ">=0.3.0" }, + { name = "qsv-client", specifier = ">=0.4.0" }, { name = "redis", specifier = ">=5.0.0" }, { name = "respx", marker = "extra == 'dev'", specifier = ">=0.23.1" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, @@ -2188,11 +2188,11 @@ wheels = [ [[package]] name = "qsv-client" -version = "0.3.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/5c/9ee10459a01b9cbeb42e58353ca0063819e05a0df340f30c5e1cb941caed/qsv_client-0.3.0.tar.gz", hash = "sha256:f6f8598fb614305e13726c7d021b70cc7b859595aea8b8c533ffc897402372a3", size = 42007, upload-time = "2026-10-10T18:17:10.593Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/1f/0861f152882257c69cf657312713eb5a5693111fe27d269217004514028d/qsv_client-0.4.0.tar.gz", hash = "sha256:dddb288aad231c9094a138c3bef6a8c37d86fa486bd37ab5cea844b3622462d0", size = 45113, upload-time = "2026-10-15T22:06:52.95Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/fe/54d1fbc0c9679afef699c9156daa9b75d2b16e9a40b55d94433f9e1a6af4/qsv_client-0.3.0-py3-none-any.whl", hash = "sha256:0b7ffb3faa88ce3d0dcb2bab87fe0008d2597c4285595016061ccc726dff5ba9", size = 31994, upload-time = "2026-10-10T18:17:09.338Z" }, + { url = "https://files.pythonhosted.org/packages/f4/95/d97697ee673f7492aa4c451920e4aef1c2de44df3203f5ad1371827384f5/qsv_client-0.4.0-py3-none-any.whl", hash = "sha256:88f90356bb87cc67a73193a70443b459a36fe4afc49fbc8a7f11f1354c333834", size = 32675, upload-time = "2026-10-15T22:06:51.49Z" }, ] [[package]]