From f0d1ec1f96263ed5134506ce6e5c0575d2544c1e Mon Sep 17 00:00:00 2001 From: Vericto <295450070+vericto@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:25:11 -0500 Subject: [PATCH 1/3] test: cover the alternate flows and gate CI at 80% line and branch coverage Coverage before -> after (line / branch, core job plus the integration jobs combined): 92.1% / 80.2% -> 99.8% / 99.6%. The core job alone, the way `uv run pytest` measures it with the integrations skipped, goes from 74.7% of lines to 99% of the modules it can import. New tests assert behavior, not lines: - vericto.dbapi.wrap() against a recording fake driver: allowed, BLOCKED (never reaches the driver), FLAGGED (runs, reported, warns), masked (runs the rewrite with the driver's placeholders), executemany as one evaluation, every DB-API paramstyle, keyword parameters, the execute() shortcut, context managers, attribute pass-through, a guard built from VERICTO_* and an unsupported dialect refused before any SQL. - Runtime API failures: connection errors and timeouts retried, 429 and 5xx exhausted, quota not retried, 401/403/400, malformed bodies, an oversized statement (400 VALIDATION_ERROR) and an empty one, Retry-After parsing and its cap, the dialect list kept from the last good answer; fail-closed vs fail-open over HTTP (a rejected key is never failed open). - Guard: LRU cache and clear, unevaluated verdicts never cached, check() vs enforce(), logging without SQL, percent placeholders mapped back. - Verdict parsing (unknown and server-sent UNEVALUATED decisions fail closed), the CI-check client, sanitize and placeholder edge cases, sqlite/snowflake scripts (masked scripts keep statement boundaries), execute_async with parameters, dbt manifest and selection edge cases, the LangChain/LlamaIndex helpers in vericto._agent, and the uncovered branches of the LangChain, LlamaIndex and Airflow integrations. Two strict xfails document a real bug: the sanitizer used by vericto-dbt in sanitized workspaces has not picked up vericto-cli 1.8.0's rules, so an existing $1 becomes $$1 (an opening dollar quote) and Snowflake gets $N placeholders instead of ?. Recorded under Known issues in the CHANGELOG. CI: the core job runs `coverage run -m pytest` and reports every module except the three integrations; each extras job reports its own module. fail_under = 80 lives in [tool.coverage.report]. coverage[toml] joins the dev group; `uv lock` (uv 0.10.8, as CI) only adds coverage 7.16.2, and re-derives the python < 3.15 markers of the Airflow dependencies, with no version change. No production code changes, no version bump. --- .github/workflows/ci.yml | 18 +- CHANGELOG.md | 20 ++ README.md | 19 ++ pyproject.toml | 12 + tests/test_agent.py | 155 ++++++++++ tests/test_airflow.py | 56 ++++ tests/test_ci_api.py | 231 +++++++++++++++ tests/test_dbapi.py | 371 ++++++++++++++++++++++++ tests/test_dbt.py | 138 +++++++++ tests/test_guard.py | 131 +++++++++ tests/test_langchain.py | 59 ++++ tests/test_llamaindex.py | 30 ++ tests/test_placeholders.py | 58 ++++ tests/test_runtime_api.py | 236 +++++++++++++++ tests/test_sanitize.py | 73 +++++ tests/test_snowflake.py | 91 ++++++ tests/test_sqlite.py | 95 +++++- tests/test_verdict.py | 120 ++++++++ uv.lock | 571 +++++++++++++++++++++++-------------- 19 files changed, 2263 insertions(+), 221 deletions(-) create mode 100644 tests/test_agent.py create mode 100644 tests/test_ci_api.py create mode 100644 tests/test_dbapi.py create mode 100644 tests/test_sanitize.py create mode 100644 tests/test_verdict.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b18306e..05246b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,13 @@ jobs: version: ${{ env.UV_VERSION }} python-version: ${{ matrix.python }} - run: uv sync --locked - - run: uv run pytest + - name: Test, with line and branch coverage (fails under 80%) + env: + # Covered in their own "extras" job: their framework is not installed here. + INTEGRATIONS: "*/vericto/langchain.py,*/vericto/llamaindex.py,*/vericto/airflow/*" + run: | + uv run coverage run -m pytest + uv run coverage report --omit="$INTEGRATIONS" extras: # One job per optional integration, against the framework versions pinned in @@ -77,22 +83,26 @@ jobs: python: "3.13" requirements: ci/requirements-langchain.txt tests: tests/test_langchain.py + covers: src/vericto/langchain.py typecheck: src/vericto/langchain.py src/vericto/_agent.py tests/test_langchain.py - extra: llamaindex python: "3.13" requirements: ci/requirements-llamaindex.txt tests: tests/test_llamaindex.py + covers: src/vericto/llamaindex.py typecheck: src/vericto/llamaindex.py src/vericto/_agent.py tests/test_llamaindex.py - extra: dbt python: "3.13" requirements: "" tests: tests/test_dbt.py + covers: src/vericto/dbt.py typecheck: "" - extra: airflow-3.3 python: "3.13" requirements: ci/requirements-airflow-3.txt constraints: https://raw.githubusercontent.com/apache/airflow/constraints-3.3.2/constraints-3.13.txt tests: tests/test_airflow.py + covers: src/vericto/airflow/* typecheck: src/vericto/airflow tests/test_airflow.py steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -116,9 +126,13 @@ jobs: - name: Test env: TESTS: ${{ matrix.tests }} + COVERS: ${{ matrix.covers }} run: | export AIRFLOW_HOME="$RUNNER_TEMP/airflow-home" - "$RUNNER_TEMP/extra/bin/python" -m pytest $TESTS + # Line and branch coverage of this job's integration module; fails under 80% (pyproject.toml). + uv pip install --python "$RUNNER_TEMP/extra/bin/python" coverage==7.16.2 + "$RUNNER_TEMP/extra/bin/python" -m coverage run -m pytest $TESTS + "$RUNNER_TEMP/extra/bin/python" -m coverage report --include="$COVERS" - name: Type-check against the real framework if: matrix.typecheck != '' env: diff --git a/CHANGELOG.md b/CHANGELOG.md index c62c6f2..639c330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ All notable changes to this package are documented here. The format follows ## [Unreleased] +### Added + +- Tests for the alternate flows: the Runtime API down, slow, rate-limited, over + quota, answering 401/403/400 or a malformed body, under both fail modes; empty, + oversized and multi-statement SQL; bind parameters in every DB-API paramstyle; + `vericto.dbapi.wrap()` against a fake driver; Snowflake `execute_async`; + the CI-check client; the shared LangChain/LlamaIndex helpers. Line coverage is + 99.8% and branch coverage 99.6% across the core and integration jobs (was 92.1% + and 80.2%). No change to the package itself. +- CI fails a job under 80% line and branch coverage (`coverage` in the dev + group, `[tool.coverage]` in `pyproject.toml`); see README, "Coverage". + +### Known issues + +- In a workspace in `sanitized` telemetry mode, `vericto-dbt` rewrites an + existing `$1` (a PostgreSQL bind parameter, a Snowflake stage column) as `$$1`, + which opens a dollar-quoted string, and uses `$N` placeholders for Snowflake + where vericto-cli 1.8.0 uses `?`. The port has not picked up vericto-cli + 1.8.0's sanitizer rules yet; two `xfail` tests in `tests/test_sanitize.py` track it. + ## [0.3.0] - 2026-10-18 A support-policy release: Python 3.9 and Airflow 2 are dropped. The API is diff --git a/README.md b/README.md index c72c8c2..c86627a 100644 --- a/README.md +++ b/README.md @@ -408,6 +408,25 @@ uv venv /tmp/vericto-lc && uv pip install --python /tmp/vericto-lc -e . -r ci/re /tmp/vericto-lc/bin/python -m pytest tests/test_langchain.py ``` +### Coverage + +CI measures line and branch coverage and fails a job under **80%** +(`fail_under` in `pyproject.toml`). The core job reports every module except the +three integrations, whose framework it does not install; each integration job +reports its own module (`langchain.py`, `llamaindex.py`, `dbt.py`, `airflow/`). +The same checks locally: + +```bash +uv run coverage run -m pytest +uv run coverage report --omit="*/vericto/langchain.py,*/vericto/llamaindex.py,*/vericto/airflow/*" +uv pip install --python /tmp/vericto-lc coverage==7.16.2 +/tmp/vericto-lc/bin/python -m coverage run -m pytest tests/test_langchain.py +/tmp/vericto-lc/bin/python -m coverage report --include=src/vericto/langchain.py +``` + +A test that documents a known bug is marked `xfail(strict=True)` with the reason, +so it fails the run as soon as the bug is fixed and the marker must go. + ### Live tests `tests/live/` holds opt-in tests against real services. They skip unless their diff --git a/pyproject.toml b/pyproject.toml index cd21430..f31ad17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ dev = [ "mypy>=1.14", "sqlalchemy>=2.0", "snowflake-connector-python>=3.12", + "coverage[toml]>=7.10", ] [tool.hatch.version] @@ -83,6 +84,17 @@ testpaths = ["tests"] addopts = "-ra --strict-markers" filterwarnings = ["error::vericto.errors.VerictoFailOpenWarning"] +# Line and branch coverage. CI fails a job under 80%: the core job reports every module +# except the integrations (their framework is not installed there), and each +# integration job reports its own module (.github/workflows/ci.yml). +[tool.coverage.run] +branch = true +source = ["vericto"] + +[tool.coverage.report] +fail_under = 80 +show_missing = true + [tool.ruff] line-length = 120 target-version = "py310" diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 0000000..f0386fd --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,155 @@ +"""The framework-free pieces shared by the LangChain and LlamaIndex integrations (vericto._agent). + +They need SQLAlchemy only, so they run in the core job; the integration jobs exercise them +again through the real frameworks. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, List + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Engine + +import vericto.sqlalchemy +from tests.conftest import FakeVericto, blocked, masked +from vericto import Decision, Finding, Verdict, Vericto, VerictoBlocked, VerictoConfigError +from vericto._agent import EngineGate, block_message, check_on_block, describe_verdict + + +def verdict(decision: Decision, **kw: Any) -> Verdict: + return Verdict(decision=decision, sql="SELECT 1", dialect="postgres", **kw) + + +@pytest.mark.parametrize("value", ["return", "RAISE", " raise "]) +def test_check_on_block_accepts(value: str) -> None: + assert check_on_block(value) == value.strip().lower() + + +def test_check_on_block_rejects() -> None: + with pytest.raises(VerictoConfigError, match="on_block must be"): + check_on_block("ignore") + + +def test_block_message_with_everything() -> None: + v = verdict( + Decision.BLOCKED, + rule_code="VERICTO-010", + severity="critical", + message="DELETE without WHERE", + suggested_fix="DELETE FROM t WHERE id = 1", + findings=(Finding("VERICTO-010"), Finding("VERICTO-030")), + access_denied=({"schema": "hr", "table": "salaries", "column": "amount"}, {"other": 1}, "raw.table"), + event_id="evt-9", + ) + text_ = block_message(VerictoBlocked(v, "DELETE FROM t")) + assert text_.splitlines() == [ + "Error: Vericto blocked this query (VERICTO-010, severity critical). It was NOT executed.", + "Reason: DELETE without WHERE", + "Other findings: VERICTO-030.", + "Not allowed for this agent: hr.salaries.amount, {'other': 1}, raw.table.", + "Suggested fix: DELETE FROM t WHERE id = 1", + "Rewrite the query following the suggested fix and try again.", + "(Vericto event evt-9)", + ] + + +def test_block_message_minimal() -> None: + text_ = block_message(VerictoBlocked(verdict(Decision.BLOCKED), "X")) + assert text_.splitlines() == [ + "Error: Vericto blocked this query (a Vericto rule). It was NOT executed.", + "Rewrite the query so that it complies with this rule, or tell the user it cannot be run.", + ] + + +@pytest.mark.parametrize( + ("v", "expected"), + [ + (verdict(Decision.ALLOWED), "ALLOWED: Vericto found no problem with this query."), + ( + verdict(Decision.UNEVALUATED, message="timeout"), + "UNEVALUATED: Vericto could not evaluate the query (timeout).", + ), + (verdict(Decision.UNEVALUATED), "UNEVALUATED: Vericto could not evaluate the query (no verdict)."), + ( + verdict(Decision.PARSE_ERROR, parse_error="near FROMM"), + "PARSE_ERROR: Vericto could not parse the query; the workspace policy lets it run. Parser: near FROMM", + ), + ( + verdict(Decision.PARSE_ERROR), + "PARSE_ERROR: Vericto could not parse the query; the workspace policy lets it run.", + ), + ( + verdict(Decision.FLAGGED, rule_code="VERICTO-030", message="no LIMIT"), + "FLAGGED: allowed to run (VERICTO-030). no LIMIT", + ), + (verdict(Decision.MONITORED), "MONITORED: allowed to run (a Vericto rule)."), + ( + verdict(Decision.ALLOWED, rewritten_query="SELECT '***'"), + "ALLOWED: allowed to run (a Vericto rule). Sensitive columns are masked: the query that will run is: " + "SELECT '***'", + ), + ], +) +def test_describe_verdict(v: Verdict, expected: str) -> None: + assert describe_verdict(v) == expected + + +def test_describe_blocked_is_the_block_message() -> None: + assert describe_verdict(verdict(Decision.BLOCKED, rule_code="V-1")).startswith( + "Error: Vericto blocked this query (V-1)" + ) + + +# -- EngineGate --------------------------------------------------------------------- + + +@pytest.fixture +def engine(tmp_path: Path) -> Engine: + eng = create_engine(f"sqlite:///{tmp_path / 'gate.db'}") + with eng.begin() as c: + c.execute(text("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)")) + c.execute(text("INSERT INTO users VALUES (1, 'ana@example.com')")) + return eng + + +def _emails(engine: Engine) -> List[Any]: + with engine.connect() as c: + return list(c.execute(text("SELECT email FROM users WHERE id = :id"), {"id": 1}).scalars()) + + +def test_gate_checks_only_inside_active(fake: FakeVericto, guard: Vericto, engine: Engine) -> None: + gate = EngineGate(engine, guard, "sqlite", internal={"SELECT 42"}) + _emails(engine) + assert fake.requests == [] # outside active(): not the integration's SQL + with gate.active(): + assert _emails(engine) == ["ana@example.com"] + with engine.connect() as c: + c.execute(text("SELECT 42")) # framework-internal statement: let through + assert fake.queries() == ["SELECT email FROM users WHERE id = ?"] + gate.remove() + gate.remove() # idempotent + + +def test_gate_blocks_and_masks(fake: FakeVericto, guard: Vericto, engine: Engine) -> None: + fake.on(lambda sql: sql.startswith("DELETE"), blocked()) + fake.on(lambda sql: sql.startswith("SELECT email"), masked("SELECT '***' AS email FROM users WHERE id = ?")) + gate = EngineGate(engine, guard, "sqlite") + with gate.active(): + assert _emails(engine) == ["***"] + with pytest.raises(VerictoBlocked), engine.begin() as c: + c.execute(text("DELETE FROM users")) + assert _emails(engine) == ["ana@example.com"] + gate.remove() + + +def test_gate_passthrough_adds_no_listener(fake: FakeVericto, guard: Vericto, engine: Engine) -> None: + remove = vericto.sqlalchemy.instrument(engine, guard=guard) + gate = EngineGate(engine, guard, "sqlite", passthrough=True) + with gate.active(): + _emails(engine) + assert len(fake.requests) == 1 # evaluated once, by instrument()'s listener + gate.remove() + remove() diff --git a/tests/test_airflow.py b/tests/test_airflow.py index 4725666..8e840d0 100644 --- a/tests/test_airflow.py +++ b/tests/test_airflow.py @@ -407,3 +407,59 @@ def _reset_shared_guard() -> Iterator[None]: yield module._shared = None + + +class _BrokenHook: + conn_type = "mysql" + + @property + def connection(self) -> Any: + raise RuntimeError("no connection configured") + + +def test_dialect_when_the_connection_cannot_be_read() -> None: + assert dialect_for_hook(_BrokenHook()) == "mysql" # falls back to the hook's own conn_type + assert dialect_for_hook(_Hook("postgres", "not a mapping")) == "postgres" + + +def test_non_text_sql_is_refused(fake: FakeVericto, guard: Vericto) -> None: + with pytest.raises(TypeError, match="str or list of str"): + check_task_sql(_Task(123), guard=guard) + assert fake.requests == [] + + +def test_list_sql_skips_blank_entries_and_keeps_a_list_when_masked(fake: FakeVericto, guard: Vericto) -> None: + fake.on(lambda sql: "email" in sql, masked("SELECT '***' AS email FROM users")) + task = _Task(["SELECT email FROM users", " ", None]) + verdicts = check_task_sql(task, guard=guard) + assert len(verdicts) == 1 + assert task.sql == ["SELECT '***' AS email FROM users"] + + +def test_positional_parameters_and_qmark_hooks(fake: FakeVericto, guard: Vericto) -> None: + check_task_sql(_Task("DELETE FROM users WHERE id = %s", parameters=(1,)), guard=guard) + assert fake.queries()[-1] == "DELETE FROM users WHERE id = $1" + task = _Task("DELETE FROM users WHERE id = ?", "sqlite", parameters=(1,)) + task._hook.placeholder = "?" + check_task_sql(task, guard=guard) + assert fake.queries()[-1] == "DELETE FROM users WHERE id = ?" + + +def test_shared_guard_is_built_once_from_the_environment(fake: FakeVericto, monkeypatch: pytest.MonkeyPatch) -> None: + from vericto.airflow import shared_guard + + monkeypatch.setenv("VERICTO_API_KEY", API_KEY) + monkeypatch.setenv("VERICTO_DATABASE_ID", DATABASE_ID) + monkeypatch.setenv("VERICTO_API_URL", fake.url) + first = shared_guard() + assert shared_guard() is first + check_task_sql(_Task("SELECT 1")) + assert fake.queries() == ["SELECT 1"] + + +def test_policy_is_idempotent(guard: Vericto) -> None: + stock = SQLExecuteQueryOperator(task_id="stock", conn_id=CONN_ID, sql="SELECT 1") + assert guard_sql_tasks(stock, guard=guard) is True + hook = stock._pre_execute_hook + assert guard_sql_tasks(stock, guard=guard) is True + assert stock._pre_execute_hook is hook # not wrapped twice diff --git a/tests/test_ci_api.py b/tests/test_ci_api.py new file mode 100644 index 0000000..96fdc42 --- /dev/null +++ b/tests/test_ci_api.py @@ -0,0 +1,231 @@ +"""CiCheckClient (the CI-check API used by vericto-dbt): configuration, requests and failures.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List, Tuple, cast + +import pytest +import urllib3 + +from tests.conftest import API_KEY +from vericto import VerictoConfigError, VerictoQuotaExceeded, VerictoUnavailable +from vericto.ci_api import CiCheckClient, CiFile, CiRequestError, provenance_from_env + + +class ScriptedPool: + def __init__(self, *steps: object) -> None: + self.steps = list(steps) + self.calls: List[Dict[str, Any]] = [] + + def request(self, method: str, url: str, **kwargs: Any) -> urllib3.HTTPResponse: + self.calls.append({"method": method, "url": url, **kwargs}) + step = self.steps.pop(0) + if isinstance(step, Exception): + raise step + status, body = cast(Tuple[int, Any], step) + data = body if isinstance(body, bytes) else json.dumps(body).encode() + return urllib3.HTTPResponse(body=data, status=status, preload_content=True) + + def clear(self) -> None: + self.steps.clear() + + +def client(*steps: object, max_retries: int = 1) -> tuple[CiCheckClient, ScriptedPool]: + pool = ScriptedPool(*steps) + c = CiCheckClient( + API_KEY, + api_url="https://api.example.com", + backoff=0.0, + max_retries=max_retries, + pool_manager=pool, # type: ignore[arg-type] + ) + return c, pool + + +OK = {"summary": {"total": 1}, "queries": [{"line": 1, "status": "ALLOWED"}]} + + +class TestConfiguration: + def test_rejects_a_non_vericto_key(self) -> None: + with pytest.raises(VerictoConfigError, match="must start with 'vtro_'") as info: + CiCheckClient("sk-live-secret") + assert "sk-live-secret" not in str(info.value) + + @pytest.mark.parametrize("url", ["ftp://x.example", "x.example"]) + def test_rejects_malformed_urls(self, url: str) -> None: + with pytest.raises(VerictoConfigError, match="Invalid Vericto API URL"): + CiCheckClient(API_KEY, api_url=url) + + def test_refuses_plain_http_off_localhost(self) -> None: + with pytest.raises(VerictoConfigError, match="plain HTTP"): + CiCheckClient(API_KEY, api_url="http://api.example.com") + assert CiCheckClient(API_KEY, api_url="http://127.0.0.1:8080/").api_url == "http://127.0.0.1:8080" + + def test_timeout_from_the_environment(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VERICTO_TIMEOUT", "12.5") + assert CiCheckClient(API_KEY).timeout == 12.5 + monkeypatch.setenv("VERICTO_TIMEOUT", "later") + with pytest.raises(VerictoConfigError, match="VERICTO_TIMEOUT"): + CiCheckClient(API_KEY) + with pytest.raises(VerictoConfigError, match="greater than 0"): + CiCheckClient(API_KEY, timeout=-1) + + def test_negative_retries_are_clamped(self) -> None: + assert CiCheckClient(API_KEY, max_retries=-3).max_retries == 0 + + def test_ca_bundle_and_user_agent(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + bundle = tmp_path / "ca.pem" + bundle.write_text("") + monkeypatch.setenv("VERICTO_CA_BUNDLE", str(bundle)) + c = CiCheckClient(API_KEY, user_agent="vericto-python/x (dbt)") + assert c._http.connection_pool_kw["ca_certs"] == str(bundle) + assert c._headers["User-Agent"] == "vericto-python/x (dbt)" + + +def test_config_parsing_tolerates_missing_and_wrong_types() -> None: + c, _ = client((200, {"capabilities": "check_files", "ci_checks_remaining": "lots"})) + cfg = c.config() + assert cfg.telemetry_query_mode == "raw" and not cfg.sanitized + assert cfg.capabilities == () and not cfg.check_files + assert cfg.ci_checks_remaining is None and cfg.workspace_name is None + + c, _ = client( + ( + 200, + { + "telemetry_query_mode": "sanitized", + "capabilities": ["check_files"], + "ci_checks_remaining": 7, + "ruleset_version": "rs-2", + "workspace_name": "acme", + }, + ) + ) + cfg = c.config() + assert cfg.sanitized and cfg.check_files and cfg.ci_checks_remaining == 7 + assert (cfg.ruleset_version, cfg.workspace_name) == ("rs-2", "acme") + + +def test_check_payload_carries_database_file_name_and_provenance() -> None: + c, pool = client((200, OK)) + c.check_files( + [CiFile("models/" + "x" * 600 + ".sql", "SELECT 1")], + "postgres", + database_id="db-1", + file_name="f" * 300, + provenance={"git_sha": "abc"}, + ) + sent = json.loads(pool.calls[0]["body"]) + assert sent["database_id"] == "db-1" + assert len(sent["file_name"]) == 255 + assert len(sent["files"][0]["path"]) == 512 + assert sent["provenance"] == {"git_sha": "abc"} + assert sent["output_format"] == "json" + assert pool.calls[0]["headers"]["X-API-Key"] == API_KEY + + +def test_check_queries_numbers_the_entries() -> None: + c, pool = client((200, OK)) + c.check_queries(["SELECT 1", "SELECT 2"], "mysql") + sent = json.loads(pool.calls[0]["body"]) + assert sent["queries"] == [{"line": 1, "sql": "SELECT 1"}, {"line": 2, "sql": "SELECT 2"}] + assert "database_id" not in sent and "file_name" not in sent and "provenance" not in sent + + +@pytest.mark.parametrize("body", [{"summary": {}}, b"gateway", b"[1, 2]"]) +def test_answers_without_results_are_unavailable(body: object) -> None: + c, _ = client((200, body)) + with pytest.raises(VerictoUnavailable, match="without results"): + c.check_queries(["SELECT 1"], "postgres") + + +def test_connection_errors_are_retried() -> None: + c, pool = client(urllib3.exceptions.ProtocolError("reset"), (200, OK)) + assert c.check_queries(["SELECT 1"], "postgres")["queries"] + assert len(pool.calls) == 2 + + +def test_gives_up_after_retries() -> None: + c, _ = client( + urllib3.exceptions.ProtocolError("reset"), + (503, {"error": {"code": "UNAVAILABLE", "retry_after_seconds": 0}}), + max_retries=1, + ) + with pytest.raises(VerictoUnavailable, match=r"2 attempt.*HTTP 503 UNAVAILABLE") as info: + c.check_queries(["SELECT 1"], "postgres") + assert API_KEY not in str(info.value) + + +def test_quota_is_not_retried() -> None: + c, pool = client((429, {"error": {"code": "QUOTA_EXCEEDED", "resets_at": "2026-11-01", "upgrade_url": "u"}})) + with pytest.raises(VerictoQuotaExceeded, match="CI-check allowance; it resets at 2026-11-01") as info: + c.check_queries(["SELECT 1"], "postgres") + assert info.value.upgrade_url == "u" + assert len(pool.calls) == 1 + + +@pytest.mark.parametrize("status", [401, 403]) +def test_auth_errors(status: int) -> None: + c, _ = client((status, {"error": "Invalid API key"})) # a plain-string error is used as the message + with pytest.raises(VerictoConfigError, match=f"HTTP {status}\\): Invalid API key") as info: + c.config() + assert info.value.status == status + c, _ = client((status, {})) + with pytest.raises(VerictoConfigError, match="ci_dryrun:execute scope"): + c.config() + + +def test_missing_config_endpoint_points_at_the_url() -> None: + c, _ = client((404, {})) + with pytest.raises(VerictoConfigError, match="has no /api/v1/ci/config: check VERICTO_API_URL"): + c.config() + + +def test_validation_errors_list_their_details() -> None: + details = [{"message": f"problem {i}"} for i in range(7)] + [{}] + c, _ = client((400, {"error": {"code": "VALIDATION_ERROR", "message": "Validation failed", "details": details}})) + with pytest.raises(CiRequestError) as info: + c.check_queries(["SELECT 1"], "postgres") + text = str(info.value) + assert "Validation failed (problem 0; problem 1; problem 2; problem 3; problem 4)" in text + assert "problem 5" not in text + assert info.value.status == 400 and info.value.code == "VALIDATION_ERROR" + + +def test_retry_delay_honours_retry_after_and_caps() -> None: + c = CiCheckClient(API_KEY, backoff=0.5) + assert c._delay(1, 1.5) == 1.5 + assert c._delay(1, 999.0) == 10.0 + assert 0.25 <= c._delay(1, None) <= 0.5 + assert c._delay(10, None) == 10.0 + c.close() + + +def test_provenance_github_partial_and_truncated() -> None: + env = {"GITHUB_ACTIONS": "true", "GITHUB_SHA": "a" * 100, "GITHUB_RUN_ID": "1"} + assert provenance_from_env(env) == {"ci_provider": "github", "git_sha": "a" * 64} + full = { + "GITHUB_ACTIONS": "true", + "GITHUB_REF": "refs/heads/main", + "GITHUB_ACTOR": "octo", + "GITHUB_REPOSITORY": "acme/dbt", + "GITHUB_RUN_ID": "42", + "GITHUB_SERVER_URL": "https://ghe.example", + } + assert provenance_from_env(full) == { + "ci_provider": "github", + "git_ref": "refs/heads/main", + "actor": "octo", + "ci_run_url": "https://ghe.example/acme/dbt/actions/runs/42", + } + assert provenance_from_env({}) == {} + assert provenance_from_env({"GITHUB_ACTIONS": "false", "GITLAB_CI": "false"}) == {} + + +def test_unparseable_retry_after_falls_back_to_backoff() -> None: + from vericto.ci_api import _retry_after + + assert _retry_after("tomorrow", "later") is None + assert _retry_after(None, 2) == 2.0 diff --git a/tests/test_dbapi.py b/tests/test_dbapi.py new file mode 100644 index 0000000..4c99705 --- /dev/null +++ b/tests/test_dbapi.py @@ -0,0 +1,371 @@ +"""vericto.dbapi.wrap() against a recording fake DB-API driver and the local Runtime API stand-in.""" + +from __future__ import annotations + +import sqlite3 +import sys +import types +import warnings +from typing import Any, Iterator, List, Optional + +import pytest + +import vericto +from tests.conftest import API_KEY, DATABASE_ID, FakeVericto, blocked, flagged, masked +from vericto import ( + Decision, + PlaceholderMappingError, + Verdict, + Vericto, + VerictoBlocked, + VerictoFailOpenWarning, + VerictoFlaggedWarning, + VerictoUnavailable, + VerictoUnsupportedDialect, +) +from vericto.dbapi import GuardedConnection, GuardedCursor, infer_paramstyle + +DRIVER = "vericto_fake_driver" + + +class FakeCursor: + """Records what reaches the driver; returns itself like most drivers do.""" + + def __init__(self, log: List[tuple[str, Any]], connection: Any = None) -> None: + self.log = log + self.connection = connection + self.arraysize = 1 + self.rows: List[tuple[Any, ...]] = [(1,), (2,)] + self.closed = False + + def execute(self, operation: str, parameters: Any = None) -> FakeCursor: + self.log.append((operation, parameters)) + return self + + def executemany(self, operation: str, seq_of_parameters: Any) -> FakeCursor: + self.log.append((operation, list(seq_of_parameters))) + return self + + def fetchone(self) -> Optional[tuple[Any, ...]]: + return self.rows.pop(0) if self.rows else None + + def __iter__(self) -> Iterator[tuple[Any, ...]]: + return iter(list(self.rows)) + + def __next__(self) -> tuple[Any, ...]: + if not self.rows: + raise StopIteration + return self.rows.pop(0) + + def close(self) -> None: + self.closed = True + + +class FakeConnection: + """A DB-API connection of a ``format`` driver (psycopg-like), with no execute() shortcut.""" + + def __init__(self) -> None: + self.log: List[tuple[str, Any]] = [] + self.committed = False + + def cursor(self) -> FakeCursor: + return FakeCursor(self.log, self) + + def commit(self) -> None: + self.committed = True + + +FakeConnection.__module__ = f"{DRIVER}.connection" + + +@pytest.fixture(autouse=True) +def _driver_module(monkeypatch: pytest.MonkeyPatch) -> None: + module = types.ModuleType(DRIVER) + module.paramstyle = "format" # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, DRIVER, module) + + +@pytest.fixture +def raw() -> FakeConnection: + return FakeConnection() + + +@pytest.fixture +def conn(raw: FakeConnection, guard: Vericto) -> GuardedConnection: + return vericto.wrap(raw, dialect="postgresql", guard=guard) + + +# -- everyday paths --------------------------------------------------------------- + + +def test_allowed_statement_reaches_the_driver(fake: FakeVericto, raw: FakeConnection, conn: GuardedConnection) -> None: + cur = conn.cursor() + assert isinstance(cur, GuardedCursor) + assert cur.execute("SELECT 1") is cur # the guard stays on for chained calls + assert raw.log == [("SELECT 1", None)] + assert fake.requests[-1]["dialect"] == "postgres" + assert fake.requests[-1]["database_id"] == DATABASE_ID + assert cur.fetchone() == (1,) + + +def test_blocked_statement_never_reaches_the_driver( + fake: FakeVericto, raw: FakeConnection, conn: GuardedConnection +) -> None: + fake.on(lambda sql: sql.startswith("DELETE"), blocked(fix="DELETE FROM users WHERE id = $1")) + with pytest.raises(VerictoBlocked) as info: + conn.cursor().execute("DELETE FROM users") + with pytest.raises(VerictoBlocked): + conn.cursor().executemany("DELETE FROM users WHERE id = %s", [(1,), (2,)]) + assert raw.log == [] + assert info.value.suggested_fix == "DELETE FROM users WHERE id = $1" + assert info.value.sql == "DELETE FROM users" + + +def test_flagged_statement_runs_and_is_reported(fake: FakeVericto, raw: FakeConnection, evaluator: Any) -> None: + fake.on(lambda sql: sql.startswith("SELECT *"), flagged("VERICTO-030")) + seen: List[Verdict] = [] + guard = Vericto(evaluator, on_verdict=seen.append, warn_on_flag=True) + conn = vericto.wrap(raw, dialect="postgres", guard=guard) + with pytest.warns(VerictoFlaggedWarning, match="VERICTO-030"): + conn.cursor().execute("SELECT * FROM users") + assert raw.log == [("SELECT * FROM users", None)] + assert [v.decision for v in seen] == [Decision.FLAGGED] + assert seen[0].rule_code == "VERICTO-030" + + +def test_masked_statement_runs_the_rewrite(fake: FakeVericto, raw: FakeConnection, conn: GuardedConnection) -> None: + fake.on(lambda sql: sql.startswith("SELECT email"), masked("SELECT '***' AS email FROM users WHERE id = $1")) + conn.cursor().execute("SELECT email FROM users WHERE id = %s", (7,)) + # Evaluated with the dialect's own placeholder, run with the driver's. + assert fake.requests[-1]["query"] == "SELECT email FROM users WHERE id = $1" + assert raw.log == [("SELECT '***' AS email FROM users WHERE id = %s", (7,))] + + +def test_executemany_is_one_evaluation(fake: FakeVericto, raw: FakeConnection, conn: GuardedConnection) -> None: + conn.cursor().executemany("INSERT INTO t (a, b) VALUES (%s, %s)", [(1, 2), (3, 4), (5, 6)]) + assert fake.queries() == ["INSERT INTO t (a, b) VALUES ($1, $2)"] + assert raw.log == [("INSERT INTO t (a, b) VALUES (%s, %s)", [(1, 2), (3, 4), (5, 6)])] + + +def test_bind_values_are_never_sent(fake: FakeVericto, conn: GuardedConnection) -> None: + conn.cursor().execute("SELECT * FROM users WHERE email = %s", ("ana@example.com",)) + assert "ana@example.com" not in str(fake.requests) + + +def test_masked_rewrite_that_cannot_be_mapped_back_fails_closed( + fake: FakeVericto, raw: FakeConnection, evaluator: Any +) -> None: + # A rewrite with a placeholder the statement did not have cannot be bound: nothing runs. + fake.on(lambda sql: True, masked("SELECT '***' AS email FROM users WHERE id = $1 AND org = $2")) + conn = vericto.wrap(raw, dialect="postgres", guard=Vericto(evaluator)) + with pytest.raises(PlaceholderMappingError): + conn.cursor().execute("SELECT email FROM users WHERE id = %s", (1,)) + assert raw.log == [] + + +# -- placeholders in every DB-API paramstyle ---------------------------------------- + + +@pytest.mark.parametrize( + ("paramstyle", "dialect", "sql", "evaluated"), + [ + ("qmark", "sqlite", "SELECT * FROM t WHERE a = ? AND b = ?", "SELECT * FROM t WHERE a = ? AND b = ?"), + ("numeric", "oracle", "SELECT * FROM t WHERE a = :1 AND b = :2", "SELECT * FROM t WHERE a = :1 AND b = :2"), + ("named", "oracle", "SELECT * FROM t WHERE a = :a AND b = :b", "SELECT * FROM t WHERE a = :a AND b = :b"), + ( + "format", + "mysql", + "SELECT * FROM t WHERE a = %s AND b LIKE 'x%%'", + "SELECT * FROM t WHERE a = ? AND b LIKE 'x%'", + ), + ("format", "postgres", "SELECT * FROM t WHERE a = %s AND b = %s", "SELECT * FROM t WHERE a = $1 AND b = $2"), + ( + "pyformat", + "postgres", + "SELECT * FROM t WHERE a = %(a)s OR b = %(b)s OR c = %(a)s", + "SELECT * FROM t WHERE a = $1 OR b = $2 OR c = $1", + ), + ("pyformat", "mssql", "SELECT * FROM t WHERE a = %(a)s", "SELECT * FROM t WHERE a = ?"), + ], +) +def test_every_paramstyle( + fake: FakeVericto, raw: FakeConnection, guard: Vericto, paramstyle: str, dialect: str, sql: str, evaluated: str +) -> None: + conn = vericto.wrap(raw, dialect=dialect, guard=guard, paramstyle=paramstyle) + assert conn.paramstyle == paramstyle + conn.cursor().execute(sql, {"a": 1, "b": 2} if "%(" in sql or ":a" in sql else (1, 2)) + assert fake.requests[-1]["query"] == evaluated + assert raw.log == [(sql, raw.log[0][1])] # the driver always gets the original text + + +def test_without_parameters_percent_text_is_sent_as_written( + fake: FakeVericto, raw: FakeConnection, conn: GuardedConnection +) -> None: + conn.cursor().execute("SELECT 'a%%b'") + conn.cursor().execute("SELECT 'a%%b'", None) # None means "no parameters" in DB-API + assert fake.queries() == ["SELECT 'a%%b'", "SELECT 'a%%b'"] + + +class FakeCursorKw(FakeCursor): + def execute(self, operation: str, **kwargs: Any) -> FakeCursorKw: # type: ignore[override] + self.log.append((operation, kwargs)) + return self + + +@pytest.mark.parametrize("key", ["params", "parameters", "args", "vars"]) +def test_parameters_passed_by_keyword_count( + fake: FakeVericto, raw: FakeConnection, conn: GuardedConnection, key: str +) -> None: + cur = FakeCursorKw(raw.log) + guarded = GuardedCursor(cur, conn.guard, "postgres", conn, "format") + guarded.execute("SELECT * FROM t WHERE a = %s", **{key: (1,)}) + assert fake.requests[-1]["query"] == "SELECT * FROM t WHERE a = $1" + + +def test_paramstyle_is_inferred_from_the_driver_module(raw: FakeConnection) -> None: + assert infer_paramstyle(raw) == "format" + + class PerConnection: + _paramstyle = "qmark" # snowflake-connector-python keeps it on the connection + + assert infer_paramstyle(PerConnection()) == "qmark" + assert infer_paramstyle(object()) is None # builtins has no paramstyle + assert infer_paramstyle(sqlite3.connect(":memory:")) == "qmark" + + +# -- the rest of the DB-API surface ---------------------------------------------------- + + +def test_connection_execute_shortcut(fake: FakeVericto, guard: Vericto) -> None: + raw = sqlite3.connect(":memory:") + conn = vericto.wrap(raw, dialect="sqlite3", guard=guard) + cur = conn.execute("SELECT ? + 1", (1,)) + assert isinstance(cur, GuardedCursor) # sqlite3 returns a cursor: it is guarded too + assert cur.fetchone() == (2,) + assert fake.requests[-1]["query"] == "SELECT ? + 1" + fake.on(lambda sql: sql.startswith("DROP"), blocked("VERICTO-001")) + raw.execute("CREATE TABLE t (a INT)") + with pytest.raises(VerictoBlocked): + conn.execute("DROP TABLE t") + assert raw.execute("SELECT count(*) FROM sqlite_master WHERE name = 't'").fetchone() == (1,) + + +def test_connection_execute_returning_a_non_cursor(fake: FakeVericto, guard: Vericto) -> None: + class RowCountConnection(FakeConnection): + def execute(self, operation: str) -> int: + self.log.append((operation, None)) + return 3 + + raw = RowCountConnection() + assert vericto.wrap(raw, dialect="postgres", guard=guard).execute("UPDATE t SET a = 1 WHERE b = 2") == 3 + + +def test_connection_without_execute_says_so(conn: GuardedConnection) -> None: + with pytest.raises(AttributeError, match="use cursor"): + conn.execute("SELECT 1") + + +def test_attributes_pass_through(raw: FakeConnection, conn: GuardedConnection) -> None: + assert conn.wrapped is raw + assert conn.dialect == "postgres" + conn.commit() + assert raw.committed + conn.autocommit = True + assert raw.autocommit is True + assert "dialect=postgres" in repr(conn) + + cur = conn.cursor() + assert cur.connection is conn + assert cur.wrapped.connection is raw + cur.arraysize = 100 + assert cur.wrapped.arraysize == 100 + assert "vericto guarded" in repr(cur) + assert list(iter(cur)) == [(1,), (2,)] + assert next(cur) == (1,) + + bare = GuardedCursor(FakeCursor([], raw), conn.guard, "postgres") + assert bare.connection is raw # falls back to the driver cursor's connection + + +def test_context_managers(raw: FakeConnection, conn: GuardedConnection) -> None: + # Neither the fake connection nor its cursor are context managers: the wrapper still is. + with conn as c: + assert c is conn + with c.cursor() as cur: + cur.execute("SELECT 1") + assert cur.wrapped.closed # closed on exit when the driver cursor has no __exit__ + + native = vericto.wrap(sqlite3.connect(":memory:"), dialect="sqlite", guard=conn.guard) + with native as n: + n.execute("CREATE TABLE t (a INT)") + n.execute("INSERT INTO t VALUES (1)") + assert native.wrapped.execute("SELECT count(*) FROM t").fetchone() == (1,) # committed by sqlite3's __exit__ + + +# -- configuration and availability ------------------------------------------------------ + + +def test_wrap_builds_the_guard_from_the_environment( + fake: FakeVericto, raw: FakeConnection, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("VERICTO_API_KEY", API_KEY) + monkeypatch.setenv("VERICTO_DATABASE_ID", DATABASE_ID) + monkeypatch.setenv("VERICTO_API_URL", fake.url) + monkeypatch.setenv("VERICTO_TIMEOUT", "2") + conn = vericto.wrap(raw, dialect="postgres") + assert conn.guard.fail_mode == "closed" + assert conn.guard.evaluator.api_url == fake.url # type: ignore[attr-defined] + conn.cursor().execute("SELECT 1") + assert fake.requests[-1]["_headers"]["X-Api-Key"] == API_KEY + + +def test_wrap_refuses_an_unsupported_dialect_before_any_sql( + fake: FakeVericto, raw: FakeConnection, guard: Vericto +) -> None: + fake.dialects = ["postgres"] + with pytest.raises(VerictoUnsupportedDialect): + vericto.wrap(raw, dialect="mysql", guard=guard) + assert fake.requests == [] + + +def test_runtime_api_down_fail_closed_and_fail_open(fake: FakeVericto, raw: FakeConnection, evaluator: Any) -> None: + fake.on(lambda sql: True, {"status": 503, "body": {"error": {"code": "SERVICE_UNAVAILABLE"}}}) + closed = vericto.wrap(raw, dialect="postgres", guard=Vericto(evaluator, fail_mode="closed")) + with pytest.raises(VerictoUnavailable, match="HTTP 503"): + closed.cursor().execute("SELECT 1") + assert raw.log == [] + + seen: List[Verdict] = [] + opened = vericto.wrap(raw, dialect="postgres", guard=Vericto(evaluator, fail_mode="open", on_verdict=seen.append)) + with pytest.warns(VerictoFailOpenWarning, match="ran unchecked"): + opened.cursor().execute("SELECT 1") + assert raw.log == [("SELECT 1", None)] + assert seen[0].degraded + + +def test_fail_open_keeps_the_original_text_with_parameters( + fake: FakeVericto, raw: FakeConnection, evaluator: Any +) -> None: + fake.on(lambda sql: True, {"status": 500, "body": {}}) + conn = vericto.wrap(raw, dialect="postgres", guard=Vericto(evaluator, fail_mode="open")) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", VerictoFailOpenWarning) + conn.cursor().execute("SELECT * FROM t WHERE a = %s", (1,)) + assert raw.log == [("SELECT * FROM t WHERE a = %s", (1,))] + + +def test_driver_cursor_context_manager_is_used(raw: FakeConnection, conn: GuardedConnection) -> None: + events: List[str] = [] + + class ManagedCursor(FakeCursor): + def __enter__(self) -> ManagedCursor: + events.append("enter") + return self + + def __exit__(self, *exc: Any) -> None: + events.append("exit") + + with GuardedCursor(ManagedCursor(raw.log, raw), conn.guard, "postgres") as cur: + cur.execute("SELECT 1") + assert events == ["enter", "exit"] + assert raw.log == [("SELECT 1", None)] diff --git a/tests/test_dbt.py b/tests/test_dbt.py index 8edd7ed..28a412d 100644 --- a/tests/test_dbt.py +++ b/tests/test_dbt.py @@ -501,3 +501,141 @@ def test_client_repr_hides_the_key(ci: FakeCi) -> None: ) def test_sanitize_matches_vericto_cli(sql: str, dialect: str, expected: str) -> None: assert sanitize(sql, dialect) == expected + + +# -- manifest and selection edge cases ------------------------------------------------- + + +def test_unreadable_or_foreign_manifests_are_usage_errors(tmp_path: Path) -> None: + broken = tmp_path / "broken.json" + broken.write_text("{not json") + with pytest.raises(dbt.UsageError, match="Cannot read"): + dbt.load_manifest(broken) + other = tmp_path / "other.json" + other.write_text(json.dumps({"sources": {}})) + with pytest.raises(dbt.UsageError, match="not a dbt manifest"): + dbt.load_manifest(other) + with pytest.raises(dbt.UsageError, match="not a dbt manifest"): + dbt.load_manifest(_write(tmp_path / "list.json", [1, 2])) + + +def _write(path: Path, data: Any) -> Path: + path.write_text(json.dumps(data)) + return path + + +def test_dialect_override_and_missing_adapter() -> None: + assert dbt.dialect_for("snowflake", "SNOWFLAKE") == ("snowflake", []) # same dialect: no note + name, notes = dbt.dialect_for("databricks", "postgres") + assert name == "postgres" and "evaluating as 'postgres' because of --dialect" in notes[0] + assert dbt.dialect_for(None, "mysql") == ("mysql", []) + with pytest.raises(dbt.UsageError, match=r"no metadata\.adapter_type"): + dbt.dialect_for(None, None) + + +def test_hooks_in_every_shape_and_disabled_nodes(project: Path) -> None: + def change(m: Dict[str, Any]) -> None: + node = m["nodes"]["model.jaffle.customers"] + node["config"] = { + "pre_hook": ["update audit set n = n + 1 where id = 1", {"sql": " "}, 42], # underscore spelling + "post-hook": "not a list", + } + m["nodes"]["model.jaffle.stg_customers"]["config"] = {"enabled": False} + m["nodes"]["junk"] = "not a node" + + edit_manifest(project, change) + manifest = dbt.load_manifest(project / "target" / "manifest.json") + items, _ = dbt.collect(manifest, project_dir=project, target=project / "target") + kinds = [(i.unique_id, i.kind) for i in items] + assert ("model.jaffle.customers", "pre-hook") in kinds + assert [i.sql for i in items if i.kind == "pre-hook"] == ["update audit set n = n + 1 where id = 1"] + assert not any(i.unique_id == "model.jaffle.stg_customers" for i in items) + + +def test_operation_with_plain_raw_sql_is_checked(project: Path) -> None: + def change(m: Dict[str, Any]) -> None: + node = m["nodes"]["operation.jaffle.jaffle-on-run-end-0"] + node.pop("compiled_code", None) + node["raw_code"] = "vacuum analytics.events" + + edit_manifest(project, change) + manifest = dbt.load_manifest(project / "target" / "manifest.json") + items, skipped = dbt.collect(manifest, project_dir=project, target=project / "target") + assert [i.sql for i in items if i.kind == "on-run-end"] == ["vacuum analytics.events"] + assert not any(s.kind == "on-run-end" for s in skipped) + + +def test_sql_source_run_without_run_artifacts_skips(project: Path) -> None: + manifest = dbt.load_manifest(project / "target" / "manifest.json") + shutil.rmtree(project / "target" / "run", ignore_errors=True) + items, skipped = dbt.collect(manifest, project_dir=project, target=project / "target", sql_source="run") + assert not any(i.kind in ("model", "snapshot") for i in items) + assert any("no compiled SQL" in s.reason for s in skipped) + + +def test_select_matching_nothing_is_a_clean_run( + project: Path, client: CiCheckClient, ci: FakeCi, capsys: pytest.CaptureFixture[str] +) -> None: + assert run(project, client, "--select", "does_not_exist*", "--no-hooks") == 0 + assert "Nothing to check." in capsys.readouterr().out + assert ci.requests == [] + + +def test_backend_answers_with_odd_entries(project: Path, client: CiCheckClient, ci: FakeCi, tmp_path: Path) -> None: + def odd(_request: Request) -> Response: + body = { + "queries": ["junk", {"file_index": 99, "line": 1, "status": "BLOCKED"}, {"file_index": 1, "line": 2}], + "summary": {}, + "ci_checks_remaining": "unknown", + } + return Response(json.dumps(body), status=200, content_type="application/json") + + ci.server.clear() + ci.server.expect_request("/api/v1/ci/config").respond_with_json(ci.config) + ci.server.expect_request("/api/v1/ci/check-key").respond_with_handler(odd) + out = tmp_path / "r.json" + # Out-of-range and malformed entries are ignored; a result without a status counts as BLOCKED. + assert run(project, client, "--format", "json", "--output", str(out)) == 1 + doc = json.loads(out.read_text()) + assert [r["status"] for r in doc["results"]] == ["BLOCKED"] + assert doc["ruleset_version"] is None and doc["ci_checks_remaining"] is None + + +def test_table_output_to_a_file(project: Path, client: CiCheckClient, ci: FakeCi, tmp_path: Path) -> None: + out = tmp_path / "report.txt" + assert run(project, client, "--output", str(out)) == 0 + assert "vericto-dbt: PASS" in out.read_text() + + +@pytest.mark.parametrize( + ("explicit", "expected"), + [("", ""), ("repo", "repo/"), ("repo/", "repo/")], +) +def test_sarif_uri_prefix_explicit(project: Path, explicit: str, expected: str) -> None: + assert dbt._uri_prefix(project, explicit) == expected + + +def test_sarif_uri_prefix_from_the_working_directory(project: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(project) + assert dbt._uri_prefix(project, None) == "" + monkeypatch.chdir(project / "target") + assert dbt._uri_prefix(project, None) == "" # the project is above the working directory + monkeypatch.chdir(project.parent) + assert dbt._uri_prefix(project, None) == "jaffle/" + + +def test_console_main_exits_with_the_code(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr("sys.argv", ["vericto-dbt", "check", "--project-dir", str(tmp_path / "none")]) + with pytest.raises(SystemExit) as info: + dbt.console_main() + assert info.value.code == 2 + + +@pytest.mark.filterwarnings("ignore:'vericto.__main__' found in sys.modules:RuntimeWarning") +def test_python_dash_m_vericto(monkeypatch: pytest.MonkeyPatch) -> None: + import runpy + + monkeypatch.setattr("sys.argv", ["vericto", "nope"]) + with pytest.raises(SystemExit) as info: + runpy.run_module("vericto", run_name="__main__") + assert info.value.code == 2 diff --git a/tests/test_guard.py b/tests/test_guard.py index bca93ad..b11a4ad 100644 --- a/tests/test_guard.py +++ b/tests/test_guard.py @@ -218,3 +218,134 @@ def test_deepcopy_returns_the_same_guard(guard: Vericto) -> None: holder = {"guard": guard} assert copy.deepcopy(holder)["guard"] is guard + + +def test_default_evaluator_is_built_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None: + from tests.conftest import API_KEY, DATABASE_ID + from vericto import RuntimeApiEvaluator + + monkeypatch.setenv("VERICTO_API_KEY", API_KEY) + monkeypatch.setenv("VERICTO_DATABASE_ID", DATABASE_ID) + monkeypatch.setenv("VERICTO_API_URL", "https://vericto.example") + monkeypatch.setenv("VERICTO_FAIL_MODE", " OPEN ") + guard = Vericto(timeout=3, max_retries=0) + assert isinstance(guard.evaluator, RuntimeApiEvaluator) + assert guard.evaluator.api_url == "https://vericto.example" + assert guard.evaluator.timeout == 3.0 and guard.evaluator.max_retries == 0 + assert guard.fail_mode == "open" + assert "fail_mode='open'" in repr(guard) and API_KEY not in repr(guard) + + +def test_missing_configuration_fails_at_construction() -> None: + with pytest.raises(VerictoConfigError, match="VERICTO_API_KEY"): + Vericto() + + +@pytest.mark.parametrize(("ttl", "size"), [(-1, 10), (1, 0)]) +def test_bad_cache_settings(ttl: float, size: int) -> None: + with pytest.raises(VerictoConfigError, match="cache_ttl"): + Vericto(StubEvaluator(), cache_ttl=ttl, cache_size=size) + + +def test_cache_evicts_the_least_recently_used_and_can_be_cleared() -> None: + stub = StubEvaluator() + guard = Vericto(stub, cache_ttl=60, cache_size=2) + for sql in ("A", "B", "A", "C", "A", "B"): + guard.enforce(sql, "postgres") + # A, B miss; A hits (and becomes most recent); C evicts B; A hits; B misses again. + assert [c[0] for c in stub.calls] == ["A", "B", "C", "B"] + guard.clear_cache() + guard.enforce("A", "postgres") + assert [c[0] for c in stub.calls][-1] == "A" + Vericto(stub).clear_cache() # no cache: a no-op + + +def test_cache_never_stores_unevaluated_or_answers_blocks_as_allowed() -> None: + sql = "DELETE FROM t" + stub = StubEvaluator({sql: v(Decision.BLOCKED, sql, rule_code="VERICTO-010")}) + guard = Vericto(stub, cache_ttl=60) + for _ in range(2): + with pytest.raises(VerictoBlocked): + guard.enforce(sql, "postgres") + assert len(stub.calls) == 1 # the cached verdict still blocks + + down = StubEvaluator(error=VerictoUnavailable("down")) + open_guard = Vericto(down, fail_mode="open", cache_ttl=60) + with pytest.warns(VerictoFailOpenWarning): + open_guard.enforce("SELECT 1", "postgres") + down.error = None + assert open_guard.enforce("SELECT 1", "postgres").decision is Decision.ALLOWED + assert len(down.calls) == 2 + + +def test_check_does_not_apply_the_decision() -> None: + sql = "DROP TABLE t" + guard = Vericto(StubEvaluator({sql: v(Decision.BLOCKED, sql)}), fail_mode="open") + assert guard.check(sql, "postgres").blocked # returned, not raised + with pytest.raises(TypeError): + guard.check(None, "postgres") # type: ignore[arg-type] + down = Vericto(StubEvaluator(error=VerictoUnavailable("down")), fail_mode="open") + with pytest.raises(VerictoUnavailable): # no fail mode in check() + down.check("SELECT 1", "postgres") + + +def test_every_decision_is_logged_without_sql(caplog: pytest.LogCaptureFixture) -> None: + stub = StubEvaluator( + { + "P": v(Decision.PARSE_ERROR, "P", parse_error="unexpected token"), + "M": v(Decision.MONITORED, "M", rule_code="VERICTO-050"), + "R": v(Decision.ALLOWED, "R", rewritten_query="SELECT '***'", event_id="evt-7"), + } + ) + guard = Vericto(stub) + with caplog.at_level(logging.INFO, logger="vericto"): + for sql in ("P", "M", "R"): + guard.enforce(sql, "postgres") + assert "not parsed, allowed by workspace policy" in caplog.text + assert "MONITORED rule=VERICTO-050" in caplog.text + assert "running the masked rewrite" in caplog.text and "event_id=evt-7" in caplog.text + assert "SELECT '***'" not in caplog.text + + +def test_flag_warning_without_a_rule_code() -> None: + guard = Vericto(StubEvaluator({"X": v(Decision.FLAGGED, "X")}), warn_on_flag=True) + with pytest.warns(VerictoFlaggedWarning, match="no rule code"): + guard.enforce("X", "postgres") + + +def test_percent_placeholders_are_evaluated_natively_and_the_mask_mapped_back() -> None: + evaluated = "SELECT email FROM users WHERE id = $1" + rewrite = "SELECT '***' AS email FROM users WHERE id = $1" + stub = StubEvaluator({evaluated: v(Decision.FLAGGED, evaluated, rewritten_query=rewrite)}) + seen: List[Verdict] = [] + verdict = Vericto(stub, on_verdict=seen.append).enforce( + "SELECT email FROM users WHERE id = %s", "postgres", paramstyle="format" + ) + assert stub.calls == [(evaluated, "postgres")] + assert verdict.sql == "SELECT email FROM users WHERE id = %s" # the application's text + assert verdict.sql_to_execute == "SELECT '***' AS email FROM users WHERE id = %s" + assert seen == [verdict] + + +def test_blocked_with_percent_placeholders_reports_the_application_text() -> None: + evaluated = "DELETE FROM t WHERE a = $1 OR 1 = 1" + stub = StubEvaluator({evaluated: v(Decision.BLOCKED, evaluated, rule_code="VERICTO-012")}) + with pytest.raises(VerictoBlocked) as info: + Vericto(stub).enforce("DELETE FROM t WHERE a = %s OR 1 = 1", "postgres", paramstyle="format") + assert info.value.sql == "DELETE FROM t WHERE a = %s OR 1 = 1" + assert info.value.verdict.sql == "DELETE FROM t WHERE a = %s OR 1 = 1" + + +def test_enforce_all_on_an_empty_script() -> None: + stub = StubEvaluator() + assert Vericto(stub).enforce_all([], "postgres") == [] + assert stub.calls == [] + + +def test_fail_open_in_a_batch_evaluates_everything() -> None: + stub = StubEvaluator(error=VerictoUnavailable("down")) + guard = Vericto(stub, fail_mode="open") + with pytest.warns(VerictoFailOpenWarning): + verdicts = guard.enforce_all(["SELECT 1", "SELECT 2"], "postgres") + assert [x.decision for x in verdicts] == [Decision.UNEVALUATED, Decision.UNEVALUATED] + assert [c[0] for c in stub.calls] == ["SELECT 1", "SELECT 2"] diff --git a/tests/test_langchain.py b/tests/test_langchain.py index 4308ade..5c4d3c2 100644 --- a/tests/test_langchain.py +++ b/tests/test_langchain.py @@ -218,3 +218,62 @@ def test_sql_agent_reads_the_block_and_corrects_itself(fake: FakeVericto, guard: assert result["output"] == "deleted user 2" with engine.connect() as c: assert [r[0] for r in c.execute(text("SELECT id FROM users"))] == [1] + + +def test_internal_schema_statements() -> None: + from vericto.langchain import _internal_statements + + assert _internal_statements(None) == frozenset() + statements = _internal_statements("analytics") + assert { + "SET search_path TO analytics", + "USE analytics", + "ALTER SESSION SET CURRENT_SCHEMA = analytics", + } <= statements + assert any(s.startswith("SET SCHEMA") for s in statements) + # A name SAP HANA would refuse: no SET SCHEMA statement is expected for it. + assert not any(s.startswith("SET SCHEMA") for s in _internal_statements('bad"; DROP TABLE x; --')) + + +def test_schema_database_costs_one_evaluation_per_query(fake: FakeVericto, guard: Vericto, engine: Engine) -> None: + db = VerictoSQLDatabase(engine, guard=guard, schema="main", sample_rows_in_table_info=0) + before = len(fake.requests) + assert "ana@example.com" in str(db.run("SELECT email FROM users WHERE id = 1")) + assert len(fake.requests) == before + 1 + + +def test_blocked_with_fetch_one_and_run_no_throw(fake: FakeVericto, guard: Vericto, engine: Engine) -> None: + fake.on(lambda sql: sql.startswith("DELETE"), blocked("VERICTO-001")) + db = VerictoSQLDatabase(engine, guard=guard, sample_rows_in_table_info=0) + assert str(db.run("DELETE FROM users", fetch="one")).startswith("Error: Vericto blocked this query (VERICTO-001") + assert "VERICTO-001" in str(db.run_no_throw("DELETE FROM users")) + assert _count(engine) == 2 + + +def test_database_properties(guard: Vericto, engine: Engine) -> None: + db = VerictoSQLDatabase(engine, guard=guard, sample_rows_in_table_info=0) + assert db.vericto_guard is guard and db.vericto_dialect == "sqlite" and db.on_block == "return" + + +def test_check_tool_builds_its_guard_from_the_environment(fake: FakeVericto, monkeypatch: pytest.MonkeyPatch) -> None: + from tests.conftest import API_KEY, DATABASE_ID + + monkeypatch.setenv("VERICTO_API_KEY", API_KEY) + monkeypatch.setenv("VERICTO_DATABASE_ID", DATABASE_ID) + monkeypatch.setenv("VERICTO_API_URL", fake.url) + tool = VerictoCheckSQLTool(dialect="postgresql") + assert tool.dialect == "postgres" + assert tool.invoke({"query": "SELECT 1"}).startswith("ALLOWED") + assert fake.requests[-1]["dialect"] == "postgres" + + +def test_check_tool_prefers_explicit_guard_and_dialect_over_db( + fake: FakeVericto, guard: Vericto, evaluator: Any, engine: Engine +) -> None: + db = VerictoSQLDatabase(engine, guard=guard, sample_rows_in_table_info=0) + other = Vericto(evaluator) + tool = VerictoCheckSQLTool(db=db, guard=other, dialect="snowflake") + assert tool.guard is other and tool.dialect == "snowflake" + tool.guard = None + with pytest.raises(RuntimeError, match="not initialised"): + tool.check("SELECT 1") diff --git a/tests/test_llamaindex.py b/tests/test_llamaindex.py index e521639..343e1eb 100644 --- a/tests/test_llamaindex.py +++ b/tests/test_llamaindex.py @@ -175,3 +175,33 @@ def test_nl_sql_query_engine_masks(fake: FakeVericto, guard: Vericto, engine: En ) response = query_engine.query("List the emails") assert "ana@example.com" not in str(response) and "***" in str(response) + + +def test_properties_and_close(fake: FakeVericto, guard: Vericto, engine: Engine) -> None: + db = VerictoSQLDatabase(engine, guard=guard, on_block="raise") + assert db.vericto_guard is guard and db.vericto_dialect == "sqlite" and db.on_block == "raise" + db.close_vericto() + db.close_vericto() + db.run_sql("SELECT 1") + assert fake.requests == [] + + +def test_flagged_runs_and_unknown_on_block_is_refused(fake: FakeVericto, guard: Vericto, engine: Engine) -> None: + from tests.conftest import flagged + from vericto import VerictoConfigError + + fake.on(lambda sql: "users" in sql, flagged("VERICTO-030")) + text_, meta = VerictoSQLDatabase(engine, guard=guard).run_sql("SELECT email FROM users") + assert "ana@example.com" in text_ and "vericto_blocked" not in meta + with pytest.raises(VerictoConfigError): + VerictoSQLDatabase(engine, guard=guard, on_block="ignore") # type: ignore[arg-type] + + +def test_dialect_override_is_refused_when_the_server_lacks_it( + fake: FakeVericto, guard: Vericto, engine: Engine +) -> None: + from vericto import VerictoUnsupportedDialect + + fake.dialects = ["postgres"] + with pytest.raises(VerictoUnsupportedDialect): + VerictoSQLDatabase(engine, guard=guard, dialect="sqlite") diff --git a/tests/test_placeholders.py b/tests/test_placeholders.py index f64c843..ef1ecdc 100644 --- a/tests/test_placeholders.py +++ b/tests/test_placeholders.py @@ -64,3 +64,61 @@ def test_mismatched_rewrite_fails_closed() -> None: assert pg is not None with pytest.raises(PlaceholderMappingError): from_evaluation_text("SELECT a FROM t WHERE id = $2", pg) + + +def test_only_percent_escapes_still_unescape() -> None: + text, mapping = to_evaluation_text("SELECT a %% 2 FROM t WHERE b LIKE 'x%%'", "postgres", "format") + assert text == "SELECT a % 2 FROM t WHERE b LIKE 'x%'" + assert mapping is not None and mapping.markers == () + # A rewrite is escaped again for the driver, inside and outside literals. + assert from_evaluation_text("SELECT a % 2 FROM t WHERE b LIKE 'x%'", mapping) == ( + "SELECT a %% 2 FROM t WHERE b LIKE 'x%%'" + ) + + +def test_a_lone_percent_is_not_a_marker() -> None: + text, mapping = to_evaluation_text("SELECT a % b, %(x FROM t WHERE c = %s", "mysql", "format") + assert text == "SELECT a % b, %(x FROM t WHERE c = ?" + assert mapping is not None and mapping.markers == ("%s",) + + +def test_named_markers_map_back_in_order_for_question_mark_dialects() -> None: + sql = "SELECT email FROM users WHERE id = %(id)s OR parent = %(id)s AND org = %(org)s" + text, mapping = to_evaluation_text(sql, "mysql", "pyformat") + assert text == "SELECT email FROM users WHERE id = ? OR parent = ? AND org = ?" + assert mapping is not None + rewrite = text.replace("SELECT email", "SELECT '***' AS email") + assert from_evaluation_text(rewrite, mapping) == sql.replace("SELECT email", "SELECT '***' AS email") + + +def test_backslash_escapes_in_mysql_and_snowflake_strings() -> None: + sql = r"SELECT 'it\'s %s' FROM t WHERE a = %s" + for dialect in ("mysql", "snowflake"): + text, mapping = to_evaluation_text(sql, dialect, "format") + assert text == r"SELECT 'it\'s %s' FROM t WHERE a = ?" + assert mapping is not None and mapping.markers == ("%s",) + + +def test_unterminated_literals_and_comments_run_to_the_end() -> None: + assert to_evaluation_text("SELECT %s, 'open %s", "mysql", "format")[0] == "SELECT ?, 'open %s" + assert to_evaluation_text("SELECT %s -- trailing %s", "mysql", "format")[0] == "SELECT ? -- trailing %s" + assert to_evaluation_text("SELECT %s /* open %s", "mysql", "format")[0] == "SELECT ? /* open %s" + assert to_evaluation_text("SELECT %s, $tag$ open %s", "postgres", "format")[0] == "SELECT $1, $tag$ open %s" + assert to_evaluation_text('SELECT "col%s", `x%s` FROM t WHERE a = %s', "mysql", "format")[0] == ( + 'SELECT "col%s", `x%s` FROM t WHERE a = ?' + ) + + +def test_postgres_rewrite_with_other_dollar_signs() -> None: + _, mapping = to_evaluation_text("SELECT a FROM t WHERE id = %s", "postgres", "format") + assert mapping is not None + rewritten = "SELECT a, $tag$ $1 $tag$, 'cost $5' FROM t WHERE id = $1 AND b = $" + assert from_evaluation_text(rewritten, mapping) == ( + "SELECT a, $tag$ $1 $tag$, 'cost $5' FROM t WHERE id = %s AND b = $" + ) + + +def test_doubled_quotes_stay_inside_the_literal() -> None: + text, mapping = to_evaluation_text("SELECT 'O''Brien %s' FROM t WHERE a = %s", "postgres", "format") + assert text == "SELECT 'O''Brien %s' FROM t WHERE a = $1" + assert mapping is not None and mapping.markers == ("%s",) diff --git a/tests/test_runtime_api.py b/tests/test_runtime_api.py index 341fcea..d8763ce 100644 --- a/tests/test_runtime_api.py +++ b/tests/test_runtime_api.py @@ -3,8 +3,11 @@ import json import socket import time +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, cast import pytest +import urllib3 from pytest_httpserver import HTTPServer from werkzeug import Request, Response @@ -12,12 +15,15 @@ from vericto import ( Decision, RuntimeApiEvaluator, + Vericto, VerictoConfigError, + VerictoFailOpenWarning, VerictoQuotaExceeded, VerictoUnavailable, VerictoUnsupportedDialect, __version__, ) +from vericto.runtime_api import _retry_after def make(url: str, **kwargs: object) -> RuntimeApiEvaluator: @@ -192,3 +198,233 @@ def test_repr_hides_the_key(self) -> None: ev = RuntimeApiEvaluator(API_KEY, database_id=DATABASE_ID) assert API_KEY not in repr(ev) assert "api.vericto.com" in repr(ev) + + def test_explicit_arguments_win_over_the_environment(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VERICTO_API_KEY", "vtro_from_env") + monkeypatch.setenv("VERICTO_API_URL", "https://env.example") + monkeypatch.setenv("VERICTO_TIMEOUT", "9") + ev = RuntimeApiEvaluator(API_KEY, database_id=DATABASE_ID, api_url="http://localhost:9", timeout=1) + assert ev.api_url == "http://localhost:9" # plain HTTP is fine for localhost + assert ev.timeout == 1.0 + assert ev._api_key == API_KEY + + @pytest.mark.parametrize("url", ["ftp://api.example.com", "api.example.com", "https://"]) + def test_rejects_malformed_urls(self, url: str) -> None: + with pytest.raises(VerictoConfigError, match="Invalid Vericto API URL"): + RuntimeApiEvaluator(API_KEY, database_id=DATABASE_ID, api_url=url) + + def test_rejects_non_positive_timeout_and_negative_retries(self) -> None: + with pytest.raises(VerictoConfigError, match="greater than 0"): + RuntimeApiEvaluator(API_KEY, database_id=DATABASE_ID, timeout=0) + with pytest.raises(VerictoConfigError, match="negative"): + RuntimeApiEvaluator(API_KEY, database_id=DATABASE_ID, max_retries=-1) + + def test_blank_key_from_the_environment_is_missing(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VERICTO_API_KEY", " ") + with pytest.raises(VerictoConfigError, match="No Vericto API key"): + RuntimeApiEvaluator(database_id=DATABASE_ID) + + def test_ca_bundle_from_the_environment(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + bundle = tmp_path / "ca.pem" + bundle.write_text("") + monkeypatch.setenv("VERICTO_CA_BUNDLE", str(bundle)) + ev = RuntimeApiEvaluator(API_KEY, database_id=DATABASE_ID) + assert ev._http.connection_pool_kw["ca_certs"] == str(bundle) + assert ev._http.connection_pool_kw["cert_reqs"] == "CERT_REQUIRED" + + +# -- alternate flows: the Runtime API misbehaves ---------------------------------------- + + +class ScriptedPool: + """A pool_manager stand-in: raises or answers from a script, records each call.""" + + def __init__(self, *steps: object) -> None: + self.steps = list(steps) + self.calls: List[Dict[str, Any]] = [] + + def request(self, method: str, url: str, **kwargs: Any) -> urllib3.HTTPResponse: + self.calls.append({"method": method, "url": url, **kwargs}) + step = self.steps.pop(0) + if isinstance(step, Exception): + raise step + status, body = cast(Tuple[int, Any], step) + return urllib3.HTTPResponse(body=json.dumps(body).encode(), status=status, preload_content=True) + + def clear(self) -> None: + self.steps.clear() + + +def scripted(*steps: object, max_retries: int = 2) -> tuple[RuntimeApiEvaluator, ScriptedPool]: + pool = ScriptedPool(*steps) + ev = RuntimeApiEvaluator( + API_KEY, + database_id=DATABASE_ID, + api_url="https://api.example.com", + backoff=0.0, + max_retries=max_retries, + pool_manager=pool, # type: ignore[arg-type] + ) + return ev, pool + + +def test_connection_errors_are_retried_then_succeed() -> None: + ev, pool = scripted( + urllib3.exceptions.NewConnectionError(None, "refused"), # type: ignore[arg-type] + urllib3.exceptions.ReadTimeoutError(None, "/", "read timed out"), # type: ignore[arg-type] + (200, {"decision": "ALLOWED", "event_id": "e"}), + ) + assert ev.evaluate("SELECT 1", "postgres").decision is Decision.ALLOWED + assert len(pool.calls) == 3 + assert all(c["retries"] is False and c["redirect"] is False for c in pool.calls) + assert pool.calls[0]["headers"]["X-API-Key"] == API_KEY + + +def test_connection_errors_exhaust_the_retries_without_leaking_the_key() -> None: + ev, _ = scripted(*[urllib3.exceptions.ProtocolError("reset")] * 3) + with pytest.raises(VerictoUnavailable, match=r"ProtocolError while calling https://api\.example\.com") as info: + ev.evaluate("SELECT 1", "postgres") + assert API_KEY not in str(info.value) + + +def test_rate_limit_exhausts_the_retries() -> None: + body = {"error": {"code": "RATE_LIMIT_EXCEEDED", "retry_after_seconds": 0}} + ev, pool = scripted((429, body), (429, body), max_retries=1) + with pytest.raises(VerictoUnavailable, match="HTTP 429 RATE_LIMIT_EXCEEDED"): + ev.evaluate("SELECT 1", "postgres") + assert len(pool.calls) == 2 + + +@pytest.mark.parametrize("body", [[1, 2], "just a string", {"decision_typo": "ALLOWED"}]) +def test_malformed_success_bodies_are_unavailable(body: object) -> None: + ev, _ = scripted((200, body), max_retries=0) + with pytest.raises(VerictoUnavailable, match="without a decision"): + ev.evaluate("SELECT 1", "postgres") + + +def test_quota_message_names_the_reset_and_the_upgrade_url() -> None: + error = {"code": "QUOTA_EXCEEDED", "resets_at": "2026-11-01", "upgrade_url": "https://vericto.com/pricing"} + ev, pool = scripted((429, {"error": error})) + with pytest.raises(VerictoQuotaExceeded, match="resets at 2026-11-01") as info: + ev.evaluate("SELECT 1", "postgres") + assert info.value.upgrade_url == "https://vericto.com/pricing" + assert info.value.status == 429 and info.value.code == "QUOTA_EXCEEDED" + assert len(pool.calls) == 1 + + +def test_403_without_a_block_is_a_config_error_with_a_default_reason() -> None: + ev, _ = scripted((403, {"error": {}})) + with pytest.raises(VerictoConfigError, match="lacks the proxy:evaluate scope"): + ev.evaluate("SELECT 1", "postgres") + + +def test_401_message_points_at_the_key() -> None: + ev, _ = scripted((401, {"error": {"code": "UNAUTHORIZED"}})) + with pytest.raises(VerictoConfigError, match="rejected the API key") as info: + ev.evaluate("SELECT 1", "postgres") + assert info.value.code == "UNAUTHORIZED" + + +def test_oversized_statement_rejected_by_the_server_is_not_retried() -> None: + # The backend validates the body (64 KiB per statement) and answers 400 VALIDATION_ERROR. + error = { + "code": "VALIDATION_ERROR", + "message": "Validation failed", + "details": [{"path": "query", "message": "each statement must be at most 65536 bytes"}], + } + ev, pool = scripted((400, {"error": error}), (200, {"dialects": [{"id": "postgres"}]})) + with pytest.raises(VerictoConfigError, match="HTTP 400, VALIDATION_ERROR") as info: + ev.evaluate("SELECT 1 " + "-" * 70_000, "postgres") + assert info.value.status == 400 and info.value.code == "VALIDATION_ERROR" + assert [c["method"] for c in pool.calls] == ["POST", "GET"] # not retried; the dialect list is asked once + + +def test_oversized_statement_is_not_run_even_under_fail_open() -> None: + ev, _ = scripted((400, {"error": {"code": "VALIDATION_ERROR"}}), (200, {"dialects": [{"id": "postgres"}]})) + ev._dialects = ("postgres",) + ev._dialects_at = time.monotonic() + with pytest.raises(VerictoConfigError): + Vericto(ev, fail_mode="open").enforce("SELECT 1 " + "-" * 70_000, "postgres") + + +def test_large_statement_is_sent_whole(fake: FakeVericto, evaluator: RuntimeApiEvaluator) -> None: + sql = "SELECT " + ", ".join(f"c{i}" for i in range(50_000)) + " FROM t" + assert evaluator.evaluate(sql, "postgres").decision is Decision.ALLOWED + assert fake.requests[-1]["query"] == sql + + +def test_empty_statement_is_left_to_the_server(httpserver: HTTPServer) -> None: + _reply(httpserver, 400, {"error": {"code": "VALIDATION_ERROR", "message": "query is required"}}) + httpserver.expect_request("/api/v1/meta/config").respond_with_json({"dialects": [{"id": "postgres"}]}) + with pytest.raises(VerictoConfigError, match="query is required"): + make(httpserver.url_for("").rstrip("/")).evaluate("", "postgres") + + +def test_delay_honours_retry_after_up_to_the_cap() -> None: + ev = RuntimeApiEvaluator(API_KEY, database_id=DATABASE_ID, backoff=0.2) + assert ev._delay(1, 0.5) == 0.5 + assert ev._delay(1, 3600.0) == 2.0 # a huge Retry-After never stalls the statement + for attempt, (low, high) in {1: (0.1, 0.2), 2: (0.2, 0.4), 3: (0.4, 0.8), 6: (2.0, 2.0)}.items(): + assert low <= ev._delay(attempt, None) <= high + + +@pytest.mark.parametrize( + ("header", "body", "expected"), + [ + ("3", None, 3.0), + ("Wed, 21 Oct 2026 07:28:00 GMT", 1, 1.0), # HTTP-date header: use the body value + ("-5", None, 0.0), + (None, "soon", None), + (None, None, None), + ], +) +def test_retry_after_parsing(header: Optional[str], body: object, expected: Optional[float]) -> None: + assert _retry_after(header, body) == expected + + +def test_supported_dialects_tolerates_bad_answers_and_keeps_the_last_good_one() -> None: + ev, _ = scripted( + (500, {}), + (200, {"dialects": "postgres"}), + (200, {"dialects": [{"id": "postgres"}, {"label": "no id"}, "junk"]}), + urllib3.exceptions.ProtocolError("reset"), + ) + ev._dialects_ttl = 0.0 # ask every time + assert ev.supported_dialects() is None + assert ev.supported_dialects() is None + assert ev.supported_dialects() == ("postgres",) + assert ev.supported_dialects() == ("postgres",) # unreachable: the last good answer + ev.close() + + +# -- the guard's fail mode over HTTP ------------------------------------------------------ + + +def test_slow_api_fail_closed_and_fail_open(httpserver: HTTPServer) -> None: + def slow(_request: Request) -> Response: + time.sleep(0.4) + return Response(json.dumps({"decision": "ALLOWED"}), content_type="application/json") + + httpserver.expect_request("/api/v1/meta/config").respond_with_json({"dialects": [{"id": "postgres"}]}) + httpserver.expect_request("/api/v1/proxy/query").respond_with_handler(slow) + ev = make(httpserver.url_for("").rstrip("/"), timeout=0.1, max_retries=0) + with pytest.raises(VerictoUnavailable): + Vericto(ev).enforce("SELECT 1", "postgres") + with pytest.warns(VerictoFailOpenWarning): + verdict = Vericto(ev, fail_mode="open").enforce("SELECT 1", "postgres") + assert verdict.degraded and verdict.sql_to_execute == "SELECT 1" + + +def test_fail_open_never_hides_a_rejected_key(httpserver: HTTPServer) -> None: + _reply(httpserver, 401, {"error": {"code": "UNAUTHORIZED"}}) + httpserver.expect_request("/api/v1/meta/config").respond_with_json({"dialects": [{"id": "postgres"}]}) + with pytest.raises(VerictoConfigError): + Vericto(make(httpserver.url_for("").rstrip("/")), fail_mode="open").enforce("SELECT 1", "postgres") + + +def test_fail_open_covers_quota_and_5xx_over_http(httpserver: HTTPServer) -> None: + _reply(httpserver, 429, {"error": {"code": "QUOTA_EXCEEDED"}}) + httpserver.expect_request("/api/v1/meta/config").respond_with_json({"dialects": [{"id": "postgres"}]}) + guard = Vericto(make(httpserver.url_for("").rstrip("/")), fail_mode="open") + with pytest.warns(VerictoFailOpenWarning, match="monthly query quota"): + assert guard.enforce("SELECT 1", "postgres").degraded diff --git a/tests/test_sanitize.py b/tests/test_sanitize.py new file mode 100644 index 0000000..d39e032 --- /dev/null +++ b/tests/test_sanitize.py @@ -0,0 +1,73 @@ +"""vericto.sanitize edge cases beyond the vericto-cli parity table in test_dbt.py.""" + +from __future__ import annotations + +import pytest + +from vericto.sanitize import sanitize + + +@pytest.mark.parametrize( + ("sql", "dialect", "expected"), + [ + # Dollar-quoted bodies with a tag stay; an unterminated or invalid tag is plain text. + ("SELECT $fn$ 'a' $fn$, 'b'", "postgres", "SELECT $fn$ 'a' $fn$, $1"), + ("SELECT $fn$ 'a'", "postgres", "SELECT $fn$ $1"), + ("SELECT $a1$ 'x' $a1$", "postgres", "SELECT $a1$ 'x' $a1$"), + ("SELECT $", "postgres", "SELECT $"), + ("SELECT $abc", "postgres", "SELECT $abc"), + # Unterminated quoted names, comments and strings run to the end without hanging. + ('SELECT "open', "postgres", 'SELECT "open'), + ("SELECT 1 /* open 'x'", "postgres", "SELECT $1 /* open 'x'"), + ("SELECT 1 -- 'x'", "postgres", "SELECT $1 -- 'x'"), + ("SELECT 'open", "postgres", "SELECT $1"), + # Decimals, identifiers with digits, signs. + ("WHERE price > 10.50 AND t2.x = -3", "postgres", "WHERE price > $1 AND t2.x = -$2"), + ("WHERE a = 1 AND b = 'two'", "mysql", "WHERE a = ? AND b = ?"), + # An E prefix that is the end of an identifier is not an escape string. + ("WHERE name = 'x' AND typE'y'", "postgres", "WHERE name = $1 AND typE$2"), + # Backslashes are literal outside MySQL and E-strings. + ("WHERE p = 'a\\' AND q = 'b'", "snowflake", "WHERE p = $1 AND q = $2"), + ("", "postgres", ""), + ], +) +def test_edge_cases(sql: str, dialect: str, expected: str) -> None: + assert sanitize(sql, dialect) == expected + + +def test_no_literal_survives_a_realistic_model() -> None: + sql = ( + "-- model: orders\n" + "SELECT o.id, c.email, 'VIP' AS tier, 0.15 * o.total AS discount\n" + "FROM orders o JOIN customers c ON c.id = o.customer_id\n" + "WHERE c.email = 'ana@example.com' AND o.created_at > '2026-01-01' AND o.total > 1000" + ) + out = sanitize(sql, "postgres") + for secret in ("ana@example.com", "2026-01-01", "VIP", "1000", "0.15"): + assert secret not in out + assert out.count("$") == 5 + assert out.startswith("-- model: orders\n") + + +# vericto-cli 1.8.0 (sanitize.rs, 2026-10-15) changed two rules this port has not picked up yet. +# Both cases are copied from vericto-cli's own tests. + + +@pytest.mark.xfail( + strict=True, + reason="Port drift from vericto-cli 1.8.0: existing $N bind parameters become '$$N', which opens a " + "dollar-quoted string, so the evaluated text no longer matches the statement.", +) +def test_existing_bind_parameters_are_kept() -> None: + assert sanitize("SELECT a FROM t WHERE id = $1 AND name = $2", "postgres") == ( + "SELECT a FROM t WHERE id = $1 AND name = $2" + ) + + +@pytest.mark.xfail( + strict=True, + reason="Port drift from vericto-cli 1.8.0: Snowflake placeholders are '?' there and '$N' here, and the " + "stage column reference $1 becomes '$$1'.", +) +def test_snowflake_uses_question_marks_and_keeps_stage_columns() -> None: + assert sanitize("SELECT $1 FROM @stage WHERE a = 'x'", "snowflake") == "SELECT $1 FROM @stage WHERE a = ?" diff --git a/tests/test_snowflake.py b/tests/test_snowflake.py index e711c61..b0c751a 100644 --- a/tests/test_snowflake.py +++ b/tests/test_snowflake.py @@ -188,3 +188,94 @@ def test_guarded_cursor_context_manager_and_attributes(raw: FakeSnowflakeConnect cur.execute("SELECT 1") cur.arraysize = 50 assert cur.wrapped.arraysize == 50 + + +def test_execute_async_with_parameters_and_keywords(fake: FakeVericto, raw: FakeSnowflakeConnection, conn: Any) -> None: + fake.on(lambda sql: sql.startswith("DELETE"), blocked()) + cur = conn.cursor() + cur.execute_async("SELECT * FROM orders WHERE id = %s", (7,)) + assert fake.requests[-1]["query"] == "SELECT * FROM orders WHERE id = ?" + cur.execute_async(command="SELECT * FROM orders WHERE id = %(id)s", params={"id": 7}) + assert fake.requests[-1]["query"] == "SELECT * FROM orders WHERE id = ?" + with pytest.raises(VerictoBlocked): + cur.execute_async(command="DELETE FROM orders WHERE id = %s OR 1 = 1", params=(1,)) + assert raw.log == ["SELECT * FROM orders WHERE id = %s", "SELECT * FROM orders WHERE id = %(id)s"] + + +def test_execute_async_runs_the_masked_rewrite(fake: FakeVericto, raw: FakeSnowflakeConnection, conn: Any) -> None: + fake.on(lambda sql: sql.startswith("SELECT email"), masked("SELECT '***' AS email FROM users WHERE id = ?")) + assert conn.cursor().execute_async("SELECT email FROM users WHERE id = %s", (1,)) == {"queryId": "01b2-async"} + assert raw.log == ["SELECT '***' AS email FROM users WHERE id = %s"] + + +def test_executemany_with_pyformat(fake: FakeVericto, raw: FakeSnowflakeConnection, conn: Any) -> None: + conn.cursor().executemany("INSERT INTO t (a) VALUES (%(a)s)", [{"a": 1}, {"a": 2}]) + assert fake.queries() == ["INSERT INTO t (a) VALUES (?)"] + assert raw.log == ["INSERT INTO t (a) VALUES (%(a)s)"] + + +def test_execute_string_without_cursors(raw: FakeSnowflakeConnection, conn: Any) -> None: + assert conn.execute_string("SELECT 1; SELECT 2;", return_cursors=False) == [] + assert raw.log == ["SELECT 1;", "SELECT 2;"] + + +def test_execute_string_with_remove_comments(fake: FakeVericto, raw: FakeSnowflakeConnection, conn: Any) -> None: + conn.execute_string("SELECT 1; -- note\nSELECT 2;", remove_comments=True) + assert fake.queries() == ["SELECT 1;", "SELECT 2;"] + + +def test_masked_script_with_comments_keeps_statement_boundaries( + fake: FakeVericto, raw: FakeSnowflakeConnection, conn: Any +) -> None: + fake.on(lambda sql: sql.startswith("SELECT email"), masked("SELECT '***' AS email FROM users")) + conn.execute_string("SELECT email FROM users; -- trailing note\nSELECT 2 -- no terminator") + # Still two statements: the terminator added after the trailing comment is never needed. + assert vericto.snowflake.split_script(raw.scripts[-1], remove_comments=True) == [ + "SELECT '***' AS email FROM users;", + "SELECT 2", + ] + + +def test_execute_string_under_fail_open(fake: FakeVericto, raw: FakeSnowflakeConnection, evaluator: Any) -> None: + from vericto import VerictoFailOpenWarning, VerictoUnavailable + + fake.on(lambda sql: True, {"status": 504, "body": {}}) + closed = vericto.snowflake.wrap(raw, guard=Vericto(evaluator)) + with pytest.raises(VerictoUnavailable): + closed.execute_string("SELECT 1; SELECT 2;") + assert raw.scripts == [] + opened = vericto.snowflake.wrap(raw, guard=Vericto(evaluator, fail_mode="open")) + with pytest.warns(VerictoFailOpenWarning): + opened.execute_string("SELECT 1; SELECT 2;") + assert raw.scripts == ["SELECT 1; SELECT 2;"] + + +def test_split_script_matches_what_the_connector_runs() -> None: + # execute_string runs every non-empty piece, bare ';' and comment-only ones included, + # so each of them is evaluated too. + assert vericto.snowflake.split_script("SELECT 1;; ;\n-- c\n") == ["SELECT 1;", ";", ";", "-- c"] + assert vericto.snowflake.split_script("SELECT 1;; ;\n-- c\n", remove_comments=True) == ["SELECT 1;", ";", ";"] + assert vericto.snowflake.split_script("") == [] + + +def test_wrap_builds_the_guard_from_the_environment( + fake: FakeVericto, raw: FakeSnowflakeConnection, monkeypatch: pytest.MonkeyPatch +) -> None: + from tests.conftest import API_KEY, DATABASE_ID + + monkeypatch.setenv("VERICTO_API_KEY", API_KEY) + monkeypatch.setenv("VERICTO_DATABASE_ID", DATABASE_ID) + monkeypatch.setenv("VERICTO_API_URL", fake.url) + vericto.snowflake.wrap(raw).cursor().execute("SELECT 1") + assert fake.requests[-1]["dialect"] == "snowflake" + + +def test_execute_string_passes_a_non_list_result_through(guard: Vericto) -> None: + class NoCursors(FakeSnowflakeConnection): + def execute_string(self, sql_text: str, remove_comments: bool = False, **kw: Any) -> Any: # type: ignore[override] + self.scripts.append(sql_text) + return None + + raw = NoCursors() + assert vericto.snowflake.wrap(raw, guard=guard).execute_string("SELECT 1;") is None + assert raw.scripts == ["SELECT 1;"] diff --git a/tests/test_sqlite.py b/tests/test_sqlite.py index 15d35a2..7ab2f14 100644 --- a/tests/test_sqlite.py +++ b/tests/test_sqlite.py @@ -9,7 +9,7 @@ import vericto.sqlite from tests.conftest import DIALECTS_WITHOUT_SQLITE, FakeVericto, blocked, masked -from vericto import Vericto, VerictoBlocked, VerictoUnsupportedDialect +from vericto import RuntimeApiEvaluator, Vericto, VerictoBlocked, VerictoUnsupportedDialect @pytest.fixture @@ -113,3 +113,96 @@ def test_foreign_cursor_factory_is_refused(db: vericto.sqlite.VerictoSQLiteConne def test_factory_argument_is_refused(guard: Vericto) -> None: with pytest.raises(TypeError): vericto.sqlite.connect(":memory:", guard=guard, factory=sqlite3.Connection) + + +def test_a_connection_without_a_guard_refuses_to_run(tmp_path: Path) -> None: + conn = sqlite3.connect(":memory:", factory=vericto.sqlite.VerictoSQLiteConnection) + with pytest.raises(RuntimeError, match=r"not opened with vericto\.sqlite\.connect"): + conn.execute("SELECT 1") + + +def test_cursor_factory_subclass_is_accepted(fake: FakeVericto, db: vericto.sqlite.VerictoSQLiteConnection) -> None: + class Mine(vericto.sqlite.VerictoSQLiteCursor): + pass + + cur = db.cursor(Mine) + assert isinstance(cur, Mine) + fake.on(lambda sql: sql.startswith("DELETE"), blocked()) + with pytest.raises(VerictoBlocked): + cur.execute("DELETE FROM users") + assert count(db) == 2 + + +def test_cursor_executemany_and_executescript_are_checked(fake: FakeVericto, db: sqlite3.Connection) -> None: + fake.on(lambda sql: sql.startswith("UPDATE"), blocked("VERICTO-002")) + cur = db.cursor() + with pytest.raises(VerictoBlocked): + cur.executemany("UPDATE users SET email = ?", [("x",), ("y",)]) + with pytest.raises(VerictoBlocked): + cur.executescript("SELECT 1; UPDATE users SET email = 'z';") + assert db.execute("SELECT email FROM users WHERE id = 1").fetchone() == ("ana@example.com",) + + +def test_named_and_numbered_parameters_are_sent_as_written(fake: FakeVericto, db: sqlite3.Connection) -> None: + assert db.execute("SELECT email FROM users WHERE id = :id", {"id": 2}).fetchone() == ("bo@example.com",) + assert db.execute("SELECT email FROM users WHERE id = ?1", (1,)).fetchone() == ("ana@example.com",) + assert fake.queries()[-2:] == ["SELECT email FROM users WHERE id = :id", "SELECT email FROM users WHERE id = ?1"] + + +def test_flagged_statement_runs_and_warns(fake: FakeVericto, evaluator: RuntimeApiEvaluator, tmp_path: Path) -> None: + from tests.conftest import flagged + from vericto import VerictoFlaggedWarning + + fake.on(lambda sql: sql == "PRAGMA foreign_keys = OFF", flagged("VERICTO-099")) + conn = vericto.sqlite.connect(str(tmp_path / "f.db"), guard=Vericto(evaluator, warn_on_flag=True)) + with pytest.warns(VerictoFlaggedWarning, match="VERICTO-099"): + conn.execute("PRAGMA foreign_keys = OFF") + assert conn.execute("PRAGMA foreign_keys").fetchone() == (0,) + + +def test_fail_open_and_fail_closed(fake: FakeVericto, evaluator: RuntimeApiEvaluator, tmp_path: Path) -> None: + from vericto import VerictoFailOpenWarning, VerictoUnavailable + + fake.on(lambda sql: True, {"status": 502, "body": {}}) + closed = vericto.sqlite.connect(str(tmp_path / "c.db"), guard=Vericto(evaluator)) + with pytest.raises(VerictoUnavailable): + closed.execute("CREATE TABLE t (a INT)") + assert sqlite3.connect(str(tmp_path / "c.db")).execute("SELECT count(*) FROM sqlite_master").fetchone() == (0,) + opened = vericto.sqlite.connect(str(tmp_path / "o.db"), guard=Vericto(evaluator, fail_mode="open")) + with pytest.warns(VerictoFailOpenWarning): + opened.executescript("CREATE TABLE t (a INT); INSERT INTO t VALUES (1);") + assert sqlite3.Cursor.execute(opened.cursor(), "SELECT a FROM t").fetchall() == [(1,)] + + +def test_split_script_edge_cases() -> None: + assert vericto.sqlite.split_script("") == [] + assert vericto.sqlite.split_script(" ;; -- nothing\n/* here */ ;") == [] + assert vericto.sqlite.split_script("SELECT 1;;SELECT 2;") == ["SELECT 1;", "SELECT 2;"] + assert vericto.sqlite.split_script("SELECT 'a;b' -- c;d\n; SELECT 2") == ["SELECT 'a;b' -- c;d\n;", "SELECT 2"] + + +def test_empty_script_evaluates_nothing(fake: FakeVericto, db: sqlite3.Connection) -> None: + before = len(fake.requests) + db.executescript("-- nothing to do\n") + assert len(fake.requests) == before + + +def test_masked_script_keeps_statement_boundaries(fake: FakeVericto, db: sqlite3.Connection) -> None: + fake.on(lambda sql: sql.startswith("CREATE VIEW"), masked("CREATE VIEW v AS SELECT id, '***' AS email FROM users")) + # The last statement has no ';' and ends with a comment. + db.executescript("CREATE VIEW v AS SELECT id, email FROM users; CREATE TABLE audit (n INT) -- tail") + assert db.execute("SELECT email FROM v WHERE id = 2").fetchone() == ("***",) + assert db.execute("SELECT count(*) FROM audit").fetchone() == (0,) + + +def test_connect_builds_the_guard_from_the_environment( + fake: FakeVericto, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + from tests.conftest import API_KEY, DATABASE_ID + + monkeypatch.setenv("VERICTO_API_KEY", API_KEY) + monkeypatch.setenv("VERICTO_DATABASE_ID", DATABASE_ID) + monkeypatch.setenv("VERICTO_API_URL", fake.url) + conn = vericto.sqlite.connect(str(tmp_path / "env.db")) + conn.execute("SELECT 1") + assert fake.requests[-1]["dialect"] == "sqlite" diff --git a/tests/test_verdict.py b/tests/test_verdict.py new file mode 100644 index 0000000..47b07c4 --- /dev/null +++ b/tests/test_verdict.py @@ -0,0 +1,120 @@ +"""Verdict.from_response(): how a Runtime API body becomes a decision, and the errors built from it.""" + +from __future__ import annotations + +from typing import Any, Dict + +import pytest + +from vericto import Decision, Finding, Verdict, VerictoBlocked, VerictoUnsupportedDialect + + +def parse(body: Dict[str, Any], sql: str = "SELECT 1") -> Verdict: + return Verdict.from_response(body, sql, "postgres") + + +@pytest.mark.parametrize("decision", ["ALLOWED", "FLAGGED", "MONITORED", "PARSE_ERROR", "BLOCKED"]) +def test_server_decisions(decision: str) -> None: + assert parse({"decision": decision}).decision is Decision(decision) + assert parse({"decision": decision.lower()}).decision is Decision(decision) + + +@pytest.mark.parametrize("decision", [None, "", "UNEVALUATED", "SOMETHING_NEW", 42]) +def test_missing_unknown_or_local_decisions_fail_closed(decision: Any) -> None: + # UNEVALUATED is the client's own fail-open marker: a server must never be able to send it. + verdict = parse({"decision": decision, "rewritten_query": "SELECT '***'"}) + assert verdict.decision is Decision.BLOCKED + assert verdict.blocked and not verdict.degraded + assert verdict.rewritten_query is None and verdict.sql_to_execute == "SELECT 1" + + +def test_full_body_is_modelled() -> None: + body = { + "decision": "FLAGGED", + "rule_code": "VERICTO-085", + "rule_id": "r-85", + "severity": "low", + "ast_node_path": "Select.projection[0]", + "rewritten_query": "SELECT '***' AS email FROM users", + "parse_error": "", + "sensitive_columns": [{"table": "users", "column": "email", "action": "mask"}], + "access_denied": [{"schema": "hr", "table": "salaries"}], + "event_id": "evt-1", + "ruleset_version": "rs-9", + "future_field": {"x": 1}, + } + verdict = parse(body, "SELECT email FROM users") + assert verdict.masked and verdict.sql_to_execute == "SELECT '***' AS email FROM users" + assert verdict.parse_error is None # "" is "absent" + assert verdict.sensitive_columns == ({"table": "users", "column": "email", "action": "mask"},) + assert verdict.access_denied == ({"schema": "hr", "table": "salaries"},) + assert verdict.event_id == "evt-1" and verdict.ruleset_version == "rs-9" + assert verdict.raw["future_field"] == {"x": 1} + assert verdict.findings == ( + Finding(rule_code="VERICTO-085", severity="low", rule_id="r-85", ast_node_path="Select.projection[0]"), + ) + assert not verdict.cached + + +def test_findings_list_takes_precedence_and_skips_malformed_items() -> None: + body = { + "decision": "BLOCKED", + "rule_code": "VERICTO-010", + "findings": [ + {"rule_code": "VERICTO-010", "severity": "critical", "suggested_safe_query": "DELETE ... WHERE id = 1"}, + {"code": "VERICTO-030", "suggested_fix": "add LIMIT"}, + "not a mapping", + {"severity": "high"}, # no rule code + ], + } + findings = parse(body).findings + assert [f.rule_code for f in findings] == ["VERICTO-010", "VERICTO-030"] + assert findings[0].suggested_fix == "DELETE ... WHERE id = 1" + assert findings[1].suggested_fix == "add LIMIT" + + +def test_violations_alias_and_fallback_to_the_deciding_rule() -> None: + assert [f.rule_code for f in parse({"decision": "FLAGGED", "violations": [{"code": "V-1"}]}).findings] == ["V-1"] + # A list with nothing usable falls back to the top-level rule. + assert [f.rule_code for f in parse({"decision": "FLAGGED", "rule_code": "V-2", "findings": [{}]}).findings] == [ + "V-2" + ] + assert parse({"decision": "ALLOWED", "findings": "garbage"}).findings == () + + +def test_default_block_message_is_not_repeated() -> None: + assert parse({"decision": "BLOCKED", "rule_code": "V-1", "error": "Query blocked by rule V-1"}).message is None + assert parse({"decision": "BLOCKED", "rule_code": "V-1", "error": "DELETE without WHERE"}).message == ( + "DELETE without WHERE" + ) + + +def test_blocked_error_message() -> None: + full = parse( + { + "decision": "BLOCKED", + "rule_code": "VERICTO-010", + "severity": "critical", + "error": "DELETE without WHERE", + "suggested_safe_query": "DELETE FROM t WHERE id = 1", + }, + "DELETE FROM t", + ) + err = VerictoBlocked(full, "DELETE FROM t") + assert str(err) == ( + "Query blocked by VERICTO-010 (severity critical): DELETE without WHERE. " + "Suggested fix: DELETE FROM t WHERE id = 1" + ) + assert str(VerictoBlocked(parse({"decision": "BLOCKED"}), "X")) == "Query blocked by a Vericto rule" + + +def test_unevaluated_verdict() -> None: + verdict = Verdict.unevaluated("SELECT 1", "sqlite", "timeout") + assert verdict.degraded and not verdict.blocked and not verdict.masked + assert verdict.message == "timeout" and verdict.sql_to_execute == "SELECT 1" + + +def test_unsupported_dialect_message() -> None: + err = VerictoUnsupportedDialect("sqlite", [], "https://api.example.com") + assert "none reported" in str(err) and err.code == "UNSUPPORTED_DIALECT" + assert err.supported == () diff --git a/uv.lock b/uv.lock index 0a49aa2..f499597 100644 --- a/uv.lock +++ b/uv.lock @@ -246,11 +246,11 @@ name = "alembic" version = "1.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mako" }, + { name = "mako", marker = "python_full_version < '3.15'" }, { name = "sqlalchemy", version = "2.0.54", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sqlalchemy", version = "2.1.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sqlalchemy", version = "2.1.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" } wheels = [ @@ -294,8 +294,8 @@ name = "apache-airflow" version = "3.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "apache-airflow-core" }, - { name = "apache-airflow-task-sdk" }, + { name = "apache-airflow-core", marker = "python_full_version < '3.15'" }, + { name = "apache-airflow-task-sdk", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e9/9e/148e8d9211c57689af9a8aafa228ff4d9b23bf23c203e973cd2fed9f1353/apache_airflow-3.3.2.tar.gz", hash = "sha256:cdbe0dcd47ff2e9ab267fcc050bc6e4fa6e40fb86bf3671e05d9672206da59bb", size = 31774, upload-time = "2026-09-17T08:39:00.645Z" } wheels = [ @@ -307,80 +307,80 @@ name = "apache-airflow-core" version = "3.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "a2wsgi" }, - { name = "aiosqlite" }, - { name = "alembic" }, + { name = "a2wsgi", marker = "python_full_version < '3.15'" }, + { name = "aiosqlite", marker = "python_full_version < '3.15'" }, + { name = "alembic", marker = "python_full_version < '3.15'" }, { name = "apache-airflow-providers-common-compat", version = "1.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "apache-airflow-providers-common-compat", version = "1.21.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "apache-airflow-providers-common-compat", version = "1.21.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, { name = "apache-airflow-providers-common-io", version = "1.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "apache-airflow-providers-common-io", version = "1.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "apache-airflow-providers-common-io", version = "1.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, { name = "apache-airflow-providers-common-sql", version = "2.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "apache-airflow-providers-common-sql", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "apache-airflow-providers-common-sql", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, { name = "apache-airflow-providers-smtp", version = "3.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "apache-airflow-providers-smtp", version = "3.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "apache-airflow-providers-smtp", version = "3.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, { name = "apache-airflow-providers-standard", version = "1.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "apache-airflow-providers-standard", version = "1.20.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "apache-airflow-task-sdk" }, - { name = "argcomplete" }, - { name = "asgiref" }, - { name = "attrs" }, - { name = "cachetools" }, - { name = "cadwyn" }, - { name = "colorlog" }, - { name = "cron-descriptor" }, - { name = "croniter" }, - { name = "cryptography" }, + { name = "apache-airflow-providers-standard", version = "1.20.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "apache-airflow-task-sdk", marker = "python_full_version < '3.15'" }, + { name = "argcomplete", marker = "python_full_version < '3.15'" }, + { name = "asgiref", marker = "python_full_version < '3.15'" }, + { name = "attrs", marker = "python_full_version < '3.15'" }, + { name = "cachetools", marker = "python_full_version < '3.15'" }, + { name = "cadwyn", marker = "python_full_version < '3.15'" }, + { name = "colorlog", marker = "python_full_version < '3.15'" }, + { name = "cron-descriptor", marker = "python_full_version < '3.15'" }, + { name = "croniter", marker = "python_full_version < '3.15'" }, + { name = "cryptography", marker = "python_full_version < '3.15'" }, { name = "deprecated", version = "1.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "deprecated", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "dill" }, - { name = "fastapi", extra = ["standard-no-fastapi-cloud-cli"] }, - { name = "httpx" }, - { name = "importlib-metadata" }, - { name = "isoduration" }, - { name = "itsdangerous" }, - { name = "jinja2" }, - { name = "jsonschema" }, - { name = "lazy-object-proxy" }, - { name = "libcst" }, - { name = "linkify-it-py" }, - { name = "lockfile" }, - { name = "methodtools" }, - { name = "msgspec" }, - { name = "natsort" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp" }, - { name = "opentelemetry-proto" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "pendulum" }, - { name = "pluggy" }, - { name = "psutil" }, - { name = "pydantic" }, - { name = "pygments" }, + { name = "deprecated", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.15'" }, + { name = "dill", marker = "python_full_version < '3.15'" }, + { name = "fastapi", extra = ["standard-no-fastapi-cloud-cli"], marker = "python_full_version < '3.15'" }, + { name = "httpx", marker = "python_full_version < '3.15'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.15'" }, + { name = "isoduration", marker = "python_full_version < '3.15'" }, + { name = "itsdangerous", marker = "python_full_version < '3.15'" }, + { name = "jinja2", marker = "python_full_version < '3.15'" }, + { name = "jsonschema", marker = "python_full_version < '3.15'" }, + { name = "lazy-object-proxy", marker = "python_full_version < '3.15'" }, + { name = "libcst", marker = "python_full_version < '3.15'" }, + { name = "linkify-it-py", marker = "python_full_version < '3.15'" }, + { name = "lockfile", marker = "python_full_version < '3.15'" }, + { name = "methodtools", marker = "python_full_version < '3.15'" }, + { name = "msgspec", marker = "python_full_version < '3.15'" }, + { name = "natsort", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-api", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-exporter-otlp", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-proto", marker = "python_full_version < '3.15'" }, + { name = "packaging", marker = "python_full_version < '3.15'" }, + { name = "pathspec", marker = "python_full_version < '3.15'" }, + { name = "pendulum", marker = "python_full_version < '3.15'" }, + { name = "pluggy", marker = "python_full_version < '3.15'" }, + { name = "psutil", marker = "python_full_version < '3.15'" }, + { name = "pydantic", marker = "python_full_version < '3.15'" }, + { name = "pygments", marker = "python_full_version < '3.15'" }, { name = "pygtrie", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pygtrie", version = "2.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pyjwt" }, - { name = "python-daemon" }, - { name = "python-dateutil" }, - { name = "python-slugify" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "rich" }, - { name = "rich-argparse" }, - { name = "setproctitle" }, + { name = "pygtrie", version = "2.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "pyjwt", marker = "python_full_version < '3.15'" }, + { name = "python-daemon", marker = "python_full_version < '3.15'" }, + { name = "python-dateutil", marker = "python_full_version < '3.15'" }, + { name = "python-slugify", marker = "python_full_version < '3.15'" }, + { name = "pyyaml", marker = "python_full_version < '3.15'" }, + { name = "requests", marker = "python_full_version < '3.15'" }, + { name = "rich", marker = "python_full_version < '3.15'" }, + { name = "rich-argparse", marker = "python_full_version < '3.15'" }, + { name = "setproctitle", marker = "python_full_version < '3.15'" }, { name = "sqlalchemy", version = "2.0.54", source = { registry = "https://pypi.org/simple" }, extra = ["asyncio"], marker = "python_full_version < '3.11'" }, - { name = "sqlalchemy", version = "2.1.4", source = { registry = "https://pypi.org/simple" }, extra = ["asyncio"], marker = "python_full_version >= '3.11'" }, + { name = "sqlalchemy", version = "2.1.4", source = { registry = "https://pypi.org/simple" }, extra = ["asyncio"], marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, { name = "starlette", version = "1.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "starlette", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "structlog" }, - { name = "svcs" }, - { name = "tabulate" }, - { name = "tenacity" }, - { name = "termcolor" }, - { name = "typing-extensions" }, - { name = "universal-pathlib" }, - { name = "uuid6" }, - { name = "uvicorn" }, + { name = "starlette", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "structlog", marker = "python_full_version < '3.15'" }, + { name = "svcs", marker = "python_full_version < '3.15'" }, + { name = "tabulate", marker = "python_full_version < '3.15'" }, + { name = "tenacity", marker = "python_full_version < '3.15'" }, + { name = "termcolor", marker = "python_full_version < '3.15'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, + { name = "universal-pathlib", marker = "python_full_version < '3.15'" }, + { name = "uuid6", marker = "python_full_version < '3.15'" }, + { name = "uvicorn", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/45/47/bcf1e5ebc134c6a39f06fe00534a8981ea195e7191ea2d60dabb7af0428e/apache_airflow_core-3.3.2.tar.gz", hash = "sha256:53f38e7749a2689a1d67c6e661284ecddaa687c1f7f3cae2d76b2eca7758210f", size = 7442317, upload-time = "2026-09-17T08:39:34.987Z" } wheels = [ @@ -414,8 +414,8 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "apache-airflow", marker = "python_full_version >= '3.11'" }, - { name = "asgiref", marker = "python_full_version >= '3.11'" }, + { name = "apache-airflow", marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "asgiref", marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a0/82/3da80b6f930510b8959d5aa5c9e39c9efe8dbc937cf03bb51248cb1060d2/apache_airflow_providers_common_compat-1.21.0.tar.gz", hash = "sha256:6b667d2bfd2118885fe8815c8ea6ba5a28dc0617e8051b0f44a139a2c31e7d23", size = 56417, upload-time = "2026-10-13T11:03:18.101Z" } wheels = [ @@ -449,8 +449,8 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "apache-airflow", marker = "python_full_version >= '3.11'" }, - { name = "apache-airflow-providers-common-compat", version = "1.21.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "apache-airflow", marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "apache-airflow-providers-common-compat", version = "1.21.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/92/2f24b81c68e7bd44d8caedc7a7cc5e98c0e1728cd2561f40752e619fd160/apache_airflow_providers_common_io-1.10.1.tar.gz", hash = "sha256:375c39def9c715951a0b373d1a5038d1e74a6a3e5a7a469834b33c2d2e86c8b6", size = 29924, upload-time = "2026-10-13T11:03:18.881Z" } wheels = [ @@ -487,11 +487,11 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "apache-airflow", marker = "python_full_version >= '3.11'" }, - { name = "apache-airflow-providers-common-compat", version = "1.21.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "methodtools", marker = "python_full_version >= '3.11'" }, - { name = "more-itertools", version = "11.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sqlparse", marker = "python_full_version >= '3.11'" }, + { name = "apache-airflow", marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "apache-airflow-providers-common-compat", version = "1.21.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "methodtools", marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "more-itertools", version = "11.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "sqlparse", marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/36/882c8b67bc7793500782c6d1f802d4dafe4ddd22b99723232f3fb424bfb3/apache_airflow_providers_common_sql-2.3.0.tar.gz", hash = "sha256:2e146e34a1876be1cfb072094c313cfcccc70e445e8f33507965cff794802b66", size = 158783, upload-time = "2026-10-13T11:03:20.444Z" } wheels = [ @@ -526,9 +526,9 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "aiosmtplib", marker = "python_full_version >= '3.11'" }, - { name = "apache-airflow", marker = "python_full_version >= '3.11'" }, - { name = "apache-airflow-providers-common-compat", version = "1.21.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "aiosmtplib", marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "apache-airflow", marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "apache-airflow-providers-common-compat", version = "1.21.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7c/c1/6776f7517588453a1ff6a8c27728d660d7357d86633aee7efa7bfd5ce0c6/apache_airflow_providers_smtp-3.1.1.tar.gz", hash = "sha256:1d03c2054cfbebc84517159973be44c4b5b5313a3fbc3f5c68b36b1911c5eb3b", size = 49693, upload-time = "2026-10-13T11:04:09.442Z" } wheels = [ @@ -562,8 +562,8 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "apache-airflow", marker = "python_full_version >= '3.11'" }, - { name = "apache-airflow-providers-common-compat", version = "1.21.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "apache-airflow", marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "apache-airflow-providers-common-compat", version = "1.21.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/88/4841dc0238a066da7875dbce63bf06117182f8b56acb4a5417cf2c43cc08/apache_airflow_providers_standard-1.20.1.tar.gz", hash = "sha256:fe54e9983618ce2f17a61c45c41553da279d68494722d8e387c9779a74606fa1", size = 227786, upload-time = "2026-10-13T11:04:13.144Z" } wheels = [ @@ -575,33 +575,33 @@ name = "apache-airflow-task-sdk" version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "apache-airflow-core" }, - { name = "asgiref" }, - { name = "attrs" }, - { name = "babel" }, - { name = "colorlog" }, - { name = "fsspec" }, - { name = "greenback" }, - { name = "httpx" }, + { name = "apache-airflow-core", marker = "python_full_version < '3.15'" }, + { name = "asgiref", marker = "python_full_version < '3.15'" }, + { name = "attrs", marker = "python_full_version < '3.15'" }, + { name = "babel", marker = "python_full_version < '3.15'" }, + { name = "colorlog", marker = "python_full_version < '3.15'" }, + { name = "fsspec", marker = "python_full_version < '3.15'" }, + { name = "greenback", marker = "python_full_version < '3.15'" }, + { name = "httpx", marker = "python_full_version < '3.15'" }, { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, - { name = "isoduration" }, - { name = "jinja2" }, - { name = "jsonschema" }, - { name = "methodtools" }, - { name = "msgspec" }, - { name = "opentelemetry-api" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "pendulum" }, - { name = "pluggy" }, - { name = "psutil" }, - { name = "pydantic" }, + { name = "isoduration", marker = "python_full_version < '3.15'" }, + { name = "jinja2", marker = "python_full_version < '3.15'" }, + { name = "jsonschema", marker = "python_full_version < '3.15'" }, + { name = "methodtools", marker = "python_full_version < '3.15'" }, + { name = "msgspec", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-api", marker = "python_full_version < '3.15'" }, + { name = "packaging", marker = "python_full_version < '3.15'" }, + { name = "pathspec", marker = "python_full_version < '3.15'" }, + { name = "pendulum", marker = "python_full_version < '3.15'" }, + { name = "pluggy", marker = "python_full_version < '3.15'" }, + { name = "psutil", marker = "python_full_version < '3.15'" }, + { name = "pydantic", marker = "python_full_version < '3.15'" }, { name = "pygtrie", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pygtrie", version = "2.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "python-dateutil" }, - { name = "structlog" }, - { name = "tenacity" }, - { name = "typing-extensions" }, + { name = "pygtrie", version = "2.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "python-dateutil", marker = "python_full_version < '3.15'" }, + { name = "structlog", marker = "python_full_version < '3.15'" }, + { name = "tenacity", marker = "python_full_version < '3.15'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/52/23/b80ddd45781d46c5924fceeb62dccffa8c7695b83ae98d5b26abad0dc442/apache_airflow_task_sdk-1.3.2.tar.gz", hash = "sha256:d9a1c7785684a215df3927b028a44b2b6927dacebd54c0d281b5906febc3efef", size = 1714211, upload-time = "2026-09-17T08:40:19.774Z" } wheels = [ @@ -622,8 +622,8 @@ name = "arrow" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "python-dateutil" }, - { name = "tzdata" }, + { name = "python-dateutil", marker = "python_full_version < '3.15'" }, + { name = "tzdata", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } wheels = [ @@ -815,13 +815,13 @@ version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-strenum", marker = "python_full_version < '3.11'" }, - { name = "fastapi" }, - { name = "jinja2" }, - { name = "pydantic" }, + { name = "fastapi", marker = "python_full_version < '3.15'" }, + { name = "jinja2", marker = "python_full_version < '3.15'" }, + { name = "pydantic", marker = "python_full_version < '3.15'" }, { name = "starlette", version = "1.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "starlette", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, + { name = "starlette", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, + { name = "typing-inspection", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/80/31/9a986a9fe20c6b52bde0dd23c9fc002388ab0c8f7b30a37217b07aa1875f/cadwyn-7.0.0.tar.gz", hash = "sha256:3b57549a37e218dffb55ac5d188639de0516207f05642b084f23627c5a44d614", size = 662353, upload-time = "2026-06-06T16:34:39.492Z" } wheels = [ @@ -1142,19 +1142,152 @@ name = "colorlog" version = "6.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "python_full_version < '3.15' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8c/55/ba79756cb90c8d69d599d57785398ac87bba7b19c80e87f4e8a562197c93/colorlog-6.12.0.tar.gz", hash = "sha256:2a7924c1dadf18b22a0eb8b06d1c7b01d5341707ec1641eb6fcc4fde0c3e8e5f", size = 18151, upload-time = "2026-07-23T13:40:40.71Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl", hash = "sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e", size = 12239, upload-time = "2026-07-23T13:40:39.562Z" }, ] +[[package]] +name = "coverage" +version = "7.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/72/db17bf5f87568ab524413693385be2eeb03e652ab56c54ea05fa85515675/coverage-7.16.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:23219888477edd736b6fcaec1272d47d93b926e999641ffea7e53a1738e70b2b", size = 223670, upload-time = "2026-09-27T12:25:34.675Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e1/285727a8a74d48de256e8605413ee4cf82301228253ea58e5ab4270138b0/coverage-7.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40c0f00899fe6181ae7f434ceb200e51f5ee4b8ed10e3b5f0b605f0cae15da87", size = 224191, upload-time = "2026-09-27T12:25:36.476Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/c737b73bac9bf5034f5ff45a4237b7a189d417a4e36751faf5d10c082cbe/coverage-7.16.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4624f80732f6b427ac58f1f59c577a0994a12e8174b5af6a027b4b58795d4c3", size = 250921, upload-time = "2026-09-27T12:25:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/29/4e/e1d38d27817d91776ca543d01276a67eace3df675a673cac32b12c167d58/coverage-7.16.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:191803c4996b499fcd78c2ad5e5f767dcc53cb4dc6de6d6a741b443a1821ef02", size = 252751, upload-time = "2026-09-27T12:25:39.291Z" }, + { url = "https://files.pythonhosted.org/packages/0e/19/14a8e44cbb2ad36ae62aaa03c5eec4a06a7f7df220ea3776af26927acbe2/coverage-7.16.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fd670ac43b709c575aefc25bf52d8a598a3bc5017bddfd0a179152ab06a2deb", size = 254616, upload-time = "2026-09-27T12:25:40.506Z" }, + { url = "https://files.pythonhosted.org/packages/f9/72/f5bcad0d9a9b450080032344fbff7ec60c1b0e3d38019f7735dee2b69645/coverage-7.16.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:705e5af11d34647efdc170c7840b6857c81cf74be96419a553f237e68e62cb72", size = 256541, upload-time = "2026-09-27T12:25:41.785Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9a/8c735234e8abb52bf5d063f98c780fa942e77c4c0225f9fcb9b33effc346/coverage-7.16.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8afd9bf35cc6a1f22eb3634808fa8e0b91902459c5721ef2e4461dfe771d7f08", size = 251589, upload-time = "2026-09-27T12:25:43.269Z" }, + { url = "https://files.pythonhosted.org/packages/35/6a/1bf6d32e55642d6972aa842640e0a8850e612d19ac7d05e2c32fa59dfd34/coverage-7.16.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3f43bac1856ba269b905302778d4df433d6006489a192174ad77ac528e395032", size = 252660, upload-time = "2026-09-27T12:25:45.051Z" }, + { url = "https://files.pythonhosted.org/packages/53/5b/05b1c0d0e9495cb056155a16066259adc1e935e7411a82a62779bf729975/coverage-7.16.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f8475460aa33ee28ac896ab1156d0bb3b6c639f7f8383c2677d3359eb35f8205", size = 250660, upload-time = "2026-09-27T12:25:46.51Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/5bc3db57fc9c56b4ef055725c38c34faa818a27d17d65e251371f7f0e3a0/coverage-7.16.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d6276d78f6fca7d0ac066d5da4165c5acd07829e8305c2cb900b738fb3a75a72", size = 254479, upload-time = "2026-09-27T12:25:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0f/4a5de66daef26eb919213f63b84f45d5065ab2df94cd767fc7c1174ac5e2/coverage-7.16.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:736fde09ea39646d11f8e3b76bd3425c075aa4dd45f24891970bb77c14ff20f5", size = 250940, upload-time = "2026-09-27T12:25:49.227Z" }, + { url = "https://files.pythonhosted.org/packages/57/d3/84cd6a11e707e422194739e9734d947746efa5e5b3358ff2d601f823ee45/coverage-7.16.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c85d54e7e8a2ca932fe8399301af9b8d5907ea2a455ffaff6e7d1208db83b943", size = 251543, upload-time = "2026-09-27T12:25:50.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/bc/3d84c2e2a95f38e346ce3730d9f0e53114f4e8aace72e6e51e014afb9992/coverage-7.16.2-cp310-cp310-win32.whl", hash = "sha256:5139009b5efd2194fc168ee9362f0e191ba612ef5d29242f9269c22f9b8f80c7", size = 225732, upload-time = "2026-09-27T12:25:51.945Z" }, + { url = "https://files.pythonhosted.org/packages/07/86/31f1f3170571a345ab9d8361a7b2f8e0c5698173fbecd1f7149b6e2089ee/coverage-7.16.2-cp310-cp310-win_amd64.whl", hash = "sha256:c3305c38a2fa21a4254f2ace7dd9ef5fc569c9a558b66e7017650b3d637fb95e", size = 226357, upload-time = "2026-09-27T12:25:53.272Z" }, + { url = "https://files.pythonhosted.org/packages/58/fa/ce3baf63d85b730398d92a7162f486f3a5e4e2cc3382a02488b3943725ba/coverage-7.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d", size = 223797, upload-time = "2026-09-27T12:25:54.756Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/9ba29c2aac7f756d479f03d45762120060f0f988788001001bf36e0e6fca/coverage-7.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5", size = 224303, upload-time = "2026-09-27T12:25:56.214Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/0d6d60906dca7d28cc1e3fce12a9861801c4fbb6cbf220ad78cd059c9467/coverage-7.16.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b", size = 254715, upload-time = "2026-09-27T12:25:57.755Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b8/9198b865679379fb165c689c64f6e11105ef380f6bd1c7673e83f73d9f5c/coverage-7.16.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db5f8394e17f877a625b257f2ba0ce8e728a499c2c1579ad66220272cd3df510", size = 256627, upload-time = "2026-09-27T12:25:59.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/79/9521462cb6072fe394701bc8974b74afd576c9c9355156c7844e1a86a42b/coverage-7.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3146d2317c75f70df2509066d979dadd941f7021cdf9b5db4bcd8568258e25", size = 258724, upload-time = "2026-09-27T12:26:00.691Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/8d7d5d633db9fe0f3182fedc731bf09f9bcf2366055735152504ad614677/coverage-7.16.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d0ced76318bab499693ff25f64faa343415187cb2e4d7befdfdd391a1cf6a", size = 260692, upload-time = "2026-09-27T12:26:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a7/76cb09c89ba46d74d37428bf93251fc14fb0bbe9e05cc2a5ef61773d318a/coverage-7.16.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:af98ad5ed9d6daaca956201e00bb429a7eb2b080426686f70a20353e0f9839f5", size = 254800, upload-time = "2026-09-27T12:26:03.369Z" }, + { url = "https://files.pythonhosted.org/packages/72/b6/2351c1979aaeb5b4a8091a75b90ca997ad60de36e181ddba267cf61dac97/coverage-7.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d56e4d21c56d2046447733f8b118409597db48c01efe898ee9ac24e858ec2d6", size = 256423, upload-time = "2026-09-27T12:26:04.751Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/ad9a4f8b5cb2d494fa9452b546fe742ed2f9d3847cc14c05e36279a3e649/coverage-7.16.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1d5d0e3b660506fb84f995814e3118a21efdc0c8eb80127da1be627d90093c17", size = 254487, upload-time = "2026-09-27T12:26:06.082Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1f/a520470472f3e8b01169bf42162b1470c9ba992230432f62ca36269bf3a0/coverage-7.16.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:17228fbca0f22976f797be94e975dcd237799c657d49551c7de1e0654d1202e9", size = 258259, upload-time = "2026-09-27T12:26:07.513Z" }, + { url = "https://files.pythonhosted.org/packages/09/d2/ff26d5938274745855fa61cfcba0245c88ccc10d98d2cbd96064f16cd5a7/coverage-7.16.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bc0b0ac781d489304b741269857f1f8338b7a26b1b89c06c0344658001ec0035", size = 254216, upload-time = "2026-09-27T12:26:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/5d832d3b06785d9f53267e4f2724a9f60c312eee6ebed9063a461d0d3b45/coverage-7.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bf1bd822ec4e387ed245bed0d71151582cf7be9e5309bc4145eefe36083d5878", size = 255125, upload-time = "2026-09-27T12:26:10.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/4d/1d33edbc2fcf7d99e384e393e712aa5a2ebbbd8409825357815982207976/coverage-7.16.2-cp311-cp311-win32.whl", hash = "sha256:7ed238d227e23cc300c3d464babdaf9f6ddc740aa1b15a77ae96136e6a7c4516", size = 225898, upload-time = "2026-09-27T12:26:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7c/676df4882118756c4f8f560c954eddb93e166d84dda8c5f0b6a829689bde/coverage-7.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:a90700f743e29aa3d75a6ff5f01953176a889c00e526194bc4d281731b88d99d", size = 226369, upload-time = "2026-09-27T12:26:13.375Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0e/a457f4a461b3c5610d845137fdd45fa465e011a64c25af440518ab1f4e41/coverage-7.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:a336eec40e3520d369b8a6cdabb4f596e69a8b42927ca074aa1452fed943238a", size = 226344, upload-time = "2026-09-27T12:26:15.127Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/f8296c63c5d542f3d21aed685e56b7031a419037d155bb3382fc0940d249/coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48", size = 223969, upload-time = "2026-09-27T12:26:16.753Z" }, + { url = "https://files.pythonhosted.org/packages/90/23/6f3dcb1423a0d43216e402ea1746e4a7c7c44f38896b97dd573790f56a40/coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99", size = 224328, upload-time = "2026-09-27T12:26:18.15Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8f3b6dc920e3fc6732f7678785a2091db439f186afbec30dbf2214d9b1f7/coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808", size = 255832, upload-time = "2026-09-27T12:26:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d", size = 258571, upload-time = "2026-09-27T12:26:21.199Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/b54cbeba3ad89082c2e441278681859e538322cc34b84b2af7ebff00080f/coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c", size = 259680, upload-time = "2026-09-27T12:26:22.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a2/0dc65ec3d61930e1e4c2e371763b15eb4290896eb343a12d5d3091308116/coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800", size = 261945, upload-time = "2026-09-27T12:26:24.336Z" }, + { url = "https://files.pythonhosted.org/packages/d6/93/5fad7a61f2c14e08e98946fc31c1c7ffc1195061bf3fdc351db3be77a863/coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148", size = 256191, upload-time = "2026-09-27T12:26:25.89Z" }, + { url = "https://files.pythonhosted.org/packages/2d/47/74e5de9227b939ece9f64e729645ddc4296bea10dbfa98721c1333c8be2e/coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7", size = 257610, upload-time = "2026-09-27T12:26:27.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/2cf28d40b43645d1b72388fe3ee7f7c747533a6a9557bb8c24a7ae74fe1a/coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad", size = 255757, upload-time = "2026-09-27T12:26:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3d/7c149fd99fc8bbc39c80db5e688d1d39fd040be2ecb78b8335a51a55b9c0/coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6", size = 259822, upload-time = "2026-09-27T12:26:30.515Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/b283fce09d5995e227bd8e513358dd7471bedc0f78abc85a925ebdb0a2f6/coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd", size = 255325, upload-time = "2026-09-27T12:26:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/bf/91/f3325edf0c4223fb1fe1532b8dbef2a1d2f729459a9a7d1a44d073bae534/coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206", size = 257191, upload-time = "2026-09-27T12:26:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/89/21eb5e83ecf2eed523c4eb3d65ae513cd082c8fd1b6deb34c4cb6c332f97/coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a", size = 226034, upload-time = "2026-09-27T12:26:35.021Z" }, + { url = "https://files.pythonhosted.org/packages/db/de/e3ad6d864c0833624b4f1f9b53f9e58e116c945e5e965c3f1e172c5e84cd/coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162", size = 226569, upload-time = "2026-09-27T12:26:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c1/bccc58ebe5489cc70628f635c1932fd371f5d7da850dbcf960f95f4c4afc/coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02", size = 226372, upload-time = "2026-09-27T12:26:38.406Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993, upload-time = "2026-09-27T12:26:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368, upload-time = "2026-09-27T12:26:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358, upload-time = "2026-09-27T12:26:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955, upload-time = "2026-09-27T12:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183, upload-time = "2026-09-27T12:26:47.208Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325, upload-time = "2026-09-27T12:26:49.084Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531, upload-time = "2026-09-27T12:26:50.509Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329, upload-time = "2026-09-27T12:26:52.005Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294, upload-time = "2026-09-27T12:26:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444, upload-time = "2026-09-27T12:26:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111, upload-time = "2026-09-27T12:26:56.851Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882, upload-time = "2026-09-27T12:26:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045, upload-time = "2026-09-27T12:26:59.983Z" }, + { url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581, upload-time = "2026-09-27T12:27:01.494Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409, upload-time = "2026-09-27T12:27:03.116Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/577fc0803dab4155dcf808faffbdd7b159256781c0874a8586e17b81b149/coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", size = 224138, upload-time = "2026-09-27T12:27:05.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", size = 224445, upload-time = "2026-09-27T12:27:06.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/963ff22d3fd27117da3b8cc442f5bdc91196f783321e1a8ff0ec43476772/coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", size = 256112, upload-time = "2026-09-27T12:27:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d4/a306940c81c6ae759e82fff27d20b7fdc6896e422b821f51313cce212b6c/coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", size = 258727, upload-time = "2026-09-27T12:27:09.927Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/d3d99d93b02517087aa05bc0cf2d04d372956b849e5443e059079901429b/coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", size = 259909, upload-time = "2026-09-27T12:27:11.55Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/39dd599181726758dd185ae4dc0c0ab3aeabf7ca70e68e145060feeaaa16/coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", size = 262481, upload-time = "2026-09-27T12:27:13.17Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/91ee43f6ded411460c359d7e1aebde4d6fd8f00a2e5394182d9d212eb23c/coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", size = 256012, upload-time = "2026-09-27T12:27:14.91Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/e9499ddc33197bd7eabcb1118ca81756fc874457b324e2b479a4804b2ad2/coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", size = 257898, upload-time = "2026-09-27T12:27:16.588Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6e/c081cb5991a0afba99f9c4ad6c74a5fce9513a38ddc64e3e6680c6fed9af/coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", size = 255938, upload-time = "2026-09-27T12:27:18.19Z" }, + { url = "https://files.pythonhosted.org/packages/b2/42/1c3d819e8f9b6eb01c2fe90874d67a8882adb9507e0bbb09361ed131ea89/coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", size = 260368, upload-time = "2026-09-27T12:27:19.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/4f/d70eac07901fd587b6ab05e659b52afe13959992aa5113bf6cce059cc572/coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", size = 255620, upload-time = "2026-09-27T12:27:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/6d87af88317d3d9a9b18a9ca1bc1673eb516917f296e579d0d4a55cb3490/coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", size = 257521, upload-time = "2026-09-27T12:27:23.358Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/90c2641170d2fa1a6757b3f8450ba2740197317b0ddd749e9604b914e886/coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", size = 226248, upload-time = "2026-09-27T12:27:25.153Z" }, + { url = "https://files.pythonhosted.org/packages/30/08/d8d0478bb02c8eb0ae20a496fc80c40fcf4d3450bd184300d682ba2d28a6/coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", size = 226732, upload-time = "2026-09-27T12:27:26.907Z" }, + { url = "https://files.pythonhosted.org/packages/32/3f/0001da22155b0a8ce063ec0f7e64ecbe17b373f306e7a74435f6d6accb72/coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", size = 226645, upload-time = "2026-09-27T12:27:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/d7/85/6d8813aff9b8b8586691a9d33c43c5604f7227622574da7cdc3d91a86861/coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", size = 224871, upload-time = "2026-09-27T12:27:30.32Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/444f3a4981ac2cda40fdcf4cc9b56a4e1a33c222abeb33e51ed3e3eb2a6b/coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", size = 225123, upload-time = "2026-09-27T12:27:32.33Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c1/980681cd7b33eb66ac835044116ef0a92e11fcc7bdd866cc89d10b1130b9/coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", size = 265049, upload-time = "2026-09-27T12:27:34.085Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e3/87679875c33bb2191f0f05544a1cc9adcc940fe0c35443a10f2df753dde5/coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", size = 267717, upload-time = "2026-09-27T12:27:36.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/64/5d372776d6eb523d4e93bafba2253f96984e3b18261c4cc56a50863c6d0d/coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", size = 270047, upload-time = "2026-09-27T12:27:37.96Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/44082ff0cbf9f97d0043f57970a71204097ec7ba606361a9fd2065393669/coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", size = 271360, upload-time = "2026-09-27T12:27:39.766Z" }, + { url = "https://files.pythonhosted.org/packages/b8/17/9a215efe25b5e0ecc87c89dbe525c4a87d14d87c8c0c7316ef140a5f6f3e/coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", size = 264736, upload-time = "2026-09-27T12:27:42.072Z" }, + { url = "https://files.pythonhosted.org/packages/a2/da/7f0a31af8e448107d4d32844bd684757f51ea907bc0c68c8fd537b2123ff/coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", size = 267600, upload-time = "2026-09-27T12:27:43.85Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/3bfecbd3366b775bacdcb3330394d356cf384b5d8f5b2146ac4b14b252b5/coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", size = 264767, upload-time = "2026-09-27T12:27:45.768Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3f/5d62163732d87e4a0c4710a0eab30f0fd6a2d480112abe2029f014fe8c9d/coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", size = 269026, upload-time = "2026-09-27T12:27:47.787Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/8e4579f225426535085a9be371cc75e3b026d058d679b80affbdfb4c3ef0/coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", size = 264147, upload-time = "2026-09-27T12:27:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/ef1f77e2c3f7bb03c2b13b9a2006f88700fdd75535ef158d70049f425c1c/coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", size = 266376, upload-time = "2026-09-27T12:27:51.551Z" }, + { url = "https://files.pythonhosted.org/packages/be/79/0cb2bf4428830dec971c718c2c841a039c084415c99e67281f5a72841aab/coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", size = 226585, upload-time = "2026-09-27T12:27:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/da17121c16667fd84998e972200ae226a41540f6ea4795776c6d99e8976f/coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", size = 227378, upload-time = "2026-09-27T12:27:55.778Z" }, + { url = "https://files.pythonhosted.org/packages/74/89/01179c62d1b7e6e33bd5001566b02d7f778cf33d3ec1e81e94ca170c517f/coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", size = 227064, upload-time = "2026-09-27T12:27:57.496Z" }, + { url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", size = 224140, upload-time = "2026-09-27T12:27:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", size = 224449, upload-time = "2026-09-27T12:28:01.299Z" }, + { url = "https://files.pythonhosted.org/packages/0c/aa/3be084d5b82e63ccdad4ed751e4acbae294673573e30481d29f8b7402eec/coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", size = 256060, upload-time = "2026-09-27T12:28:03.185Z" }, + { url = "https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", size = 259106, upload-time = "2026-09-27T12:28:05.15Z" }, + { url = "https://files.pythonhosted.org/packages/06/3d/b2d5986f2dd53fe201aa1be2e4ab204fa1aed5101e67c0dbbb419b850aee/coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", size = 260674, upload-time = "2026-09-27T12:28:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7e/b50160be3506ead12e6480d14279af7f0f17627694300a2d1fd2c42d2ff5/coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", size = 263139, upload-time = "2026-09-27T12:28:08.771Z" }, + { url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", size = 256591, upload-time = "2026-09-27T12:28:10.842Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", size = 258708, upload-time = "2026-09-27T12:28:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", size = 256562, upload-time = "2026-09-27T12:28:14.934Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", size = 261077, upload-time = "2026-09-27T12:28:17.215Z" }, + { url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", size = 256034, upload-time = "2026-09-27T12:28:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", size = 258034, upload-time = "2026-09-27T12:28:21.242Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", size = 226252, upload-time = "2026-09-27T12:28:23.102Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", size = 226728, upload-time = "2026-09-27T12:28:25.21Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", size = 226648, upload-time = "2026-09-27T12:28:27.136Z" }, + { url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", size = 224858, upload-time = "2026-09-27T12:28:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", size = 225139, upload-time = "2026-09-27T12:28:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8e/285dcef0184358044e7cbcd810a1bdc9566bc620f54702d605477155df4a/coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", size = 265090, upload-time = "2026-09-27T12:28:33.04Z" }, + { url = "https://files.pythonhosted.org/packages/06/b2/cc83f3a6e5789a4e89059c69555bc641c2efcde568405a1c06fc702951ab/coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", size = 268254, upload-time = "2026-09-27T12:28:35.135Z" }, + { url = "https://files.pythonhosted.org/packages/ac/41/f548c19530f5d66ac6e3c92bbcbc49da7261de3a458b9f3e54a3efb1a0b2/coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", size = 270695, upload-time = "2026-09-27T12:28:36.959Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/4dc27cf82ef96434d2874110ad0cc10ea4621025705dc5049862bd3bd181/coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", size = 271853, upload-time = "2026-09-27T12:28:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", size = 265644, upload-time = "2026-09-27T12:28:40.815Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", size = 268530, upload-time = "2026-09-27T12:28:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", size = 265154, upload-time = "2026-09-27T12:28:44.684Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec", size = 269799, upload-time = "2026-09-27T12:28:46.672Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", size = 265196, upload-time = "2026-09-27T12:28:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", size = 267266, upload-time = "2026-09-27T12:28:51.361Z" }, + { url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", size = 226583, upload-time = "2026-09-27T12:28:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", size = 227374, upload-time = "2026-09-27T12:28:55.158Z" }, + { url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", size = 227064, upload-time = "2026-09-27T12:28:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "cron-descriptor" version = "2.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/41/6b/9ebac1857c0e90d5ba487090f4f9b23e8bf9adead62922550621de21cbf2/cron_descriptor-2.2.1.tar.gz", hash = "sha256:6759056f43b3cabca274b5df182bb63571f2f383bdd4d9d3ff4662c527430a65", size = 53562, upload-time = "2026-10-13T12:39:33.386Z" } wheels = [ @@ -1166,7 +1299,7 @@ name = "croniter" version = "6.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "python-dateutil" }, + { name = "python-dateutil", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/37/57/2e2a65aee2a70483cb28e2b7e15a072d00a523207593b44400d4717bb100/croniter-6.2.4.tar.gz", hash = "sha256:fc124f751b1b04805c2a04b061898b436b45ab2320b045e1e052ea895de65189", size = 166267, upload-time = "2026-07-10T09:52:59.955Z" } wheels = [ @@ -1359,8 +1492,8 @@ version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dnspython", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "dnspython", version = "2.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "idna" }, + { name = "dnspython", version = "2.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "idna", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } wheels = [ @@ -1384,12 +1517,12 @@ name = "fastapi" version = "0.136.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, + { name = "annotated-doc", marker = "python_full_version < '3.15'" }, + { name = "pydantic", marker = "python_full_version < '3.15'" }, { name = "starlette", version = "1.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "starlette", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, + { name = "starlette", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, + { name = "typing-inspection", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } wheels = [ @@ -1398,15 +1531,15 @@ wheels = [ [package.optional-dependencies] standard-no-fastapi-cloud-cli = [ - { name = "email-validator" }, - { name = "fastapi-cli", extra = ["standard-no-fastapi-cloud-cli"] }, - { name = "httpx" }, - { name = "jinja2" }, - { name = "pydantic-extra-types" }, + { name = "email-validator", marker = "python_full_version < '3.15'" }, + { name = "fastapi-cli", extra = ["standard-no-fastapi-cloud-cli"], marker = "python_full_version < '3.15'" }, + { name = "httpx", marker = "python_full_version < '3.15'" }, + { name = "jinja2", marker = "python_full_version < '3.15'" }, + { name = "pydantic-extra-types", marker = "python_full_version < '3.15'" }, { name = "pydantic-settings", version = "2.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pydantic-settings", version = "2.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "python-multipart" }, - { name = "uvicorn", extra = ["standard"] }, + { name = "pydantic-settings", version = "2.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "python-multipart", marker = "python_full_version < '3.15'" }, + { name = "uvicorn", extra = ["standard"], marker = "python_full_version < '3.15'" }, ] [[package]] @@ -1414,10 +1547,10 @@ name = "fastapi-cli" version = "0.0.32" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "rich-toolkit" }, + { name = "rich-toolkit", marker = "python_full_version < '3.15'" }, { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typer" }, - { name = "uvicorn", extra = ["standard"] }, + { name = "typer", marker = "python_full_version < '3.15'" }, + { name = "uvicorn", extra = ["standard"], marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/eb/3b534c6f8e157f9ddbf2a153512307c886cad0b258739c200dd8ff8c4452/fastapi_cli-0.0.32.tar.gz", hash = "sha256:38024d2345275e1b37ce8848727a580d84901b570e96b3256d9d36a9a5039424", size = 26636, upload-time = "2026-07-16T12:16:58.678Z" } wheels = [ @@ -1426,7 +1559,7 @@ wheels = [ [package.optional-dependencies] standard-no-fastapi-cloud-cli = [ - { name = "uvicorn", extra = ["standard"] }, + { name = "uvicorn", extra = ["standard"], marker = "python_full_version < '3.15'" }, ] [[package]] @@ -1582,7 +1715,7 @@ name = "googleapis-common-protos" version = "1.75.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf" }, + { name = "protobuf", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8d/2b/6ce81972d5c8cab9705fddce3153be63222d9e12fd96f8baba5038a744dd/googleapis_common_protos-1.75.5.tar.gz", hash = "sha256:c7a866fc34ed29a3b10af627a4b9b1dc2433313ca6e959f0ae4feb132047ed72", size = 156513, upload-time = "2026-09-29T19:26:14.863Z" } wheels = [ @@ -1594,9 +1727,9 @@ name = "greenback" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "greenlet" }, - { name = "outcome" }, - { name = "sniffio" }, + { name = "greenlet", marker = "python_full_version < '3.15'" }, + { name = "outcome", marker = "python_full_version < '3.15'" }, + { name = "sniffio", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3b/d2/3b70d0f03a1e0f48d4f2348de435fa282e5530ae60812fef672cabc40a28/greenback-1.3.0.tar.gz", hash = "sha256:d1441f542ec9c6efb32a9250dd954a5b1cc1eb789294c19b1eb747f49cab818c", size = 8070613, upload-time = "2025-12-23T01:49:33.582Z" } wheels = [ @@ -1794,7 +1927,7 @@ name = "grpcio" version = "1.84.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3f/4f/4435c0aae54657258d9cfcba78598f3d9e5fe4c82ff18d78558567b90faf/grpcio-1.84.0.tar.gz", hash = "sha256:19aaf172fc2edbefccce3f6e92c5150975dbe56c45744e9e87cf72ebdf85bfbe", size = 13493876, upload-time = "2026-09-14T06:59:33.291Z" } wheels = [ @@ -2071,7 +2204,7 @@ name = "importlib-metadata" version = "9.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/7e/1e7e8dc30634b93ebb3d58a3dea569ad146e656218d3960ab04f62047b29/importlib_metadata-9.0.1.tar.gz", hash = "sha256:ab830580bc0ef3db61ce8fae716389e5462b67e033018bab6d8f80ef17172f99", size = 59124, upload-time = "2026-08-28T15:30:34.646Z" } wheels = [ @@ -2092,7 +2225,7 @@ name = "isoduration" version = "20.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "arrow" }, + { name = "arrow", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } wheels = [ @@ -2167,11 +2300,11 @@ name = "jsonschema" version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, + { name = "attrs", marker = "python_full_version < '3.15'" }, + { name = "jsonschema-specifications", marker = "python_full_version < '3.15'" }, + { name = "referencing", marker = "python_full_version < '3.15'" }, { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "rpds-py", version = "2026.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "rpds-py", version = "2026.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ @@ -2183,7 +2316,7 @@ name = "jsonschema-specifications" version = "2025.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "referencing" }, + { name = "referencing", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ @@ -2357,7 +2490,7 @@ name = "libcst" version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyyaml", marker = "python_full_version != '3.13.*'" }, + { name = "pyyaml", marker = "python_full_version < '3.13' or python_full_version == '3.14.*'" }, { name = "pyyaml-ft", marker = "python_full_version == '3.13.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/c0/098e5c91ff1537f00c85a6438b6cb1863d17144680cc91f47c87f104a200/libcst-1.9.0.tar.gz", hash = "sha256:087b58a9afe076bb08e2d726478e1f16cb928d67ffa9092817e033c335de522a", size = 914739, upload-time = "2026-07-29T21:28:43.153Z" } @@ -2647,7 +2780,7 @@ name = "mako" version = "1.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe" }, + { name = "markupsafe", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/09/e07c4b5579a79f4b16f8d4f29f6c54514ac787c4ad506b8c4f28a0e6b0bf/mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a", size = 412799, upload-time = "2026-09-22T20:54:31.509Z" } wheels = [ @@ -2659,7 +2792,7 @@ name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl" }, + { name = "mdurl", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ @@ -2835,7 +2968,7 @@ name = "methodtools" version = "0.4.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wirerope" }, + { name = "wirerope", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/13/3b/c21b74ac17befdf17b286494b02221b7a84affb1d410ff86e38ba0e14b13/methodtools-0.4.7.tar.gz", hash = "sha256:e213439dd64cfe60213f7015da6efe5dd4003fd89376db3baa09fe13ec2bb0ba", size = 3586, upload-time = "2023-02-05T13:17:54.473Z" } wheels = [ @@ -3519,7 +3652,7 @@ name = "opentelemetry-api" version = "1.45.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } wheels = [ @@ -3531,7 +3664,7 @@ name = "opentelemetry-exporter-http-transport" version = "0.66b1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, + { name = "opentelemetry-api", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/62/0c/e3ebdb4b507f66afcc905e6885a4946969bd75b45988492643356fbbdc63/opentelemetry_exporter_http_transport-0.66b1.tar.gz", hash = "sha256:443080203bf52586ce0b2ad901e8951c61833eab1aa539ae6f1f16fe9e8e7952", size = 11693, upload-time = "2026-10-06T17:32:59.65Z" } wheels = [ @@ -3540,7 +3673,7 @@ wheels = [ [package.optional-dependencies] requests = [ - { name = "requests" }, + { name = "requests", marker = "python_full_version < '3.15'" }, ] [[package]] @@ -3548,8 +3681,8 @@ name = "opentelemetry-exporter-otlp" version = "1.45.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-exporter-otlp-proto-grpc" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e3/6f/5a561048ea372894f22f58f70e8478e4cd97d9a4e2d6f014559584a29bc5/opentelemetry_exporter_otlp-1.45.1.tar.gz", hash = "sha256:d0ac35592e77663a9fabf2740b4818c57196b1b764e0c8449d0d7bb2c7b2bc67", size = 6094, upload-time = "2026-10-06T17:33:00.966Z" } wheels = [ @@ -3561,7 +3694,7 @@ name = "opentelemetry-exporter-otlp-common" version = "0.66b1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-sdk" }, + { name = "opentelemetry-sdk", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cb/19/41de712173f43057e4532d42ece7d0c6d4210d353e5752433cb14987643f/opentelemetry_exporter_otlp_common-0.66b1.tar.gz", hash = "sha256:6b1403487a2185ac1feb45fd5546fdf8630ce71c36bcefaadf51e2130e9e23f9", size = 14325, upload-time = "2026-10-06T17:33:01.725Z" } wheels = [ @@ -3573,7 +3706,7 @@ name = "opentelemetry-exporter-otlp-proto-common" version = "1.45.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-proto" }, + { name = "opentelemetry-proto", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/8e/65e85e5137991a3c493b11682151d198638a5bc1dd4b4c5f67e013c57d7c/opentelemetry_exporter_otlp_proto_common-1.45.1.tar.gz", hash = "sha256:2e4adcc3a67bcf57804fc49514f0ef64974ca7590aa3491da389852b4a0628f6", size = 18873, upload-time = "2026-10-06T17:33:04.471Z" } wheels = [ @@ -3585,14 +3718,14 @@ name = "opentelemetry-exporter-otlp-proto-grpc" version = "1.45.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "googleapis-common-protos" }, - { name = "grpcio" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-common" }, - { name = "opentelemetry-exporter-otlp-proto-common" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "typing-extensions" }, + { name = "googleapis-common-protos", marker = "python_full_version < '3.15'" }, + { name = "grpcio", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-api", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-exporter-otlp-common", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-exporter-otlp-proto-common", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-proto", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-sdk", marker = "python_full_version < '3.15'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d6/00/a82af0be959dc58495740b169c6669a86e0811f6cd353a01eda34d255db3/opentelemetry_exporter_otlp_proto_grpc-1.45.1.tar.gz", hash = "sha256:3b3dcfbfdcb4e35149fcf309972282054b45228f5c10547d0095d6578510a9a0", size = 25997, upload-time = "2026-10-06T17:33:05.114Z" } wheels = [ @@ -3604,15 +3737,15 @@ name = "opentelemetry-exporter-otlp-proto-http" version = "1.45.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "googleapis-common-protos" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-http-transport", extra = ["requests"] }, - { name = "opentelemetry-exporter-otlp-common" }, - { name = "opentelemetry-exporter-otlp-proto-common" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "requests" }, - { name = "typing-extensions" }, + { name = "googleapis-common-protos", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-api", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-exporter-http-transport", extra = ["requests"], marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-exporter-otlp-common", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-exporter-otlp-proto-common", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-proto", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-sdk", marker = "python_full_version < '3.15'" }, + { name = "requests", marker = "python_full_version < '3.15'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1b/17/26487707ea4caa97b17e6e4b5fa72133a53512ffa2f5cf7a49ef284b29cb/opentelemetry_exporter_otlp_proto_http-1.45.1.tar.gz", hash = "sha256:45c218405ce3fd879596924b1874bf9a8f6880206d61065c5a912c8e5c297fb7", size = 28839, upload-time = "2026-10-06T17:33:05.713Z" } wheels = [ @@ -3624,7 +3757,7 @@ name = "opentelemetry-proto" version = "1.45.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf" }, + { name = "protobuf", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4b/7f/15f014fb195da6c2dbb6c71399b8e76824878718e94de6454038488eed28/opentelemetry_proto-1.45.1.tar.gz", hash = "sha256:79e0fb95e4616691a469439238aa9224d75779b3e108e895d1aa125ab29ca77c", size = 46488, upload-time = "2026-10-06T17:33:11.49Z" } wheels = [ @@ -3636,9 +3769,9 @@ name = "opentelemetry-sdk" version = "1.45.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "typing-extensions" }, + { name = "opentelemetry-api", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-semantic-conventions", marker = "python_full_version < '3.15'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/79/7392e21a1c8f0c61d90b223e31c7e48cb9d452e91a6b820ad24cca5f23c4/opentelemetry_sdk-1.45.1.tar.gz", hash = "sha256:63d24a6ca645019a631e6a51999c73e93adcac1196ca640b8ae78a7cc4762bf3", size = 218324, upload-time = "2026-10-06T17:33:13.26Z" } wheels = [ @@ -3650,8 +3783,8 @@ name = "opentelemetry-semantic-conventions" version = "0.66b1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "typing-extensions" }, + { name = "opentelemetry-api", marker = "python_full_version < '3.15'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/46/e4/dbbfb2a010c4db2224a5114638acede6fe563d33cc20fb1752cebcbe6298/opentelemetry_semantic_conventions-0.66b1.tar.gz", hash = "sha256:497ca63bf383723411e8eaf60c8779e9877633c936bb641080adab59d0eb6ec8", size = 150250, upload-time = "2026-10-06T17:33:14.073Z" } wheels = [ @@ -3729,7 +3862,7 @@ name = "outcome" version = "1.3.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, + { name = "attrs", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } wheels = [ @@ -3768,8 +3901,8 @@ name = "pendulum" version = "3.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "python-dateutil" }, - { name = "tzdata" }, + { name = "python-dateutil", marker = "python_full_version < '3.15'" }, + { name = "tzdata", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d8/3f/345b5bc712ce806f3b5fbce6eea141f46713b2c1a7654eaead1b2593d5a1/pendulum-3.3.0.tar.gz", hash = "sha256:9aceb5b24e9f55381df08187397e88546b5ba82f698b741cbf1a05c5e2369116", size = 87458, upload-time = "2026-10-12T20:54:11.461Z" } wheels = [ @@ -4353,8 +4486,8 @@ name = "pydantic-extra-types" version = "2.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, - { name = "typing-extensions" }, + { name = "pydantic", marker = "python_full_version < '3.15'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" } wheels = [ @@ -4496,7 +4629,7 @@ name = "python-daemon" version = "3.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "lockfile" }, + { name = "lockfile", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/37/4f10e37bdabc058a32989da2daf29e57dc59dbc5395497f3d36d5f5e2694/python_daemon-3.1.2.tar.gz", hash = "sha256:f7b04335adc473de877f5117e26d5f1142f4c9f7cd765408f0877757be5afbf4", size = 71576, upload-time = "2024-12-03T08:41:07.843Z" } wheels = [ @@ -4538,7 +4671,7 @@ name = "python-slugify" version = "9.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "text-unidecode" }, + { name = "text-unidecode", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bd/e8/26b1af09d728d604dc16427a39f53985b22a170dbd61addac3f48db73f03/python_slugify-9.1.3.tar.gz", hash = "sha256:90e997f2e0987239ce95e12f700086eb18e1d1d3ee22624fbbdbd095afca42b6", size = 66093, upload-time = "2026-10-07T01:06:20.101Z" } wheels = [ @@ -4647,9 +4780,9 @@ name = "referencing" version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, + { name = "attrs", marker = "python_full_version < '3.15'" }, { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "rpds-py", version = "2026.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "rpds-py", version = "2026.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } @@ -4826,8 +4959,8 @@ name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, + { name = "markdown-it-py", marker = "python_full_version < '3.15'" }, + { name = "pygments", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ @@ -4839,7 +4972,7 @@ name = "rich-argparse" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "rich" }, + { name = "rich", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6a/e5/1064c43203a357d668cd42435f7a15fe6af51512d85b2104fecb937aa861/rich_argparse-1.8.0.tar.gz", hash = "sha256:679df3d832fa94ad6e4bdb07ded088cd7ea2dddc58ae9b2b46346a40b06cbc0c", size = 38940, upload-time = "2026-05-01T15:18:43.604Z" } wheels = [ @@ -4851,9 +4984,9 @@ name = "rich-toolkit" version = "0.20.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "typing-extensions" }, + { name = "click", marker = "python_full_version < '3.15'" }, + { name = "rich", marker = "python_full_version < '3.15'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/fe/e851890b72568a075a3574f760eed9014736b449fe104b16fd05a416c321/rich_toolkit-0.20.6.tar.gz", hash = "sha256:04768f19438df877c6279e6e668ab0d88b5fff5c7ef3ac302838d97f7b03472e", size = 213510, upload-time = "2026-10-08T08:51:01.479Z" } wheels = [ @@ -5566,7 +5699,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.11'" }, + { name = "anyio", marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, { name = "typing-extensions", marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e9/0c/6efb252d091ecccd7d62048ae11f0ea35cd75a4fbaeea5e30f9c3bf91d10/starlette-1.8.0.tar.gz", hash = "sha256:1565dc0b35d5737a271ed1e0e04e949f4e81198799f216d2667b0a0fb9cf9522", size = 2730457, upload-time = "2026-10-13T07:54:39.53Z" } @@ -5591,7 +5724,7 @@ name = "svcs" version = "26.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, + { name = "attrs", marker = "python_full_version < '3.15'" }, { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9b/5e/89463bf7613fe723801b8f7000f37c4c36486adcda0d115ca417e513c7cc/svcs-26.2.0.tar.gz", hash = "sha256:129efb89586c2c52295ee00b00562af7d85d5535dad149534993e25fccbd76a6", size = 915193, upload-time = "2026-08-24T12:54:16.474Z" } @@ -5819,10 +5952,10 @@ name = "typer" version = "0.27.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "rich" }, - { name = "shellingham" }, + { name = "annotated-doc", marker = "python_full_version < '3.15'" }, + { name = "colorama", marker = "python_full_version < '3.15' and sys_platform == 'win32'" }, + { name = "rich", marker = "python_full_version < '3.15'" }, + { name = "shellingham", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/03/51/d33db42cc72ffd8c30777547b42d01f0cbf9d95a770457698d0174b3ed71/typer-0.27.3.tar.gz", hash = "sha256:d0396f770a560ab1b0a8504e13b5f254b728cedb05c61cf0359e944e50ce8901", size = 205303, upload-time = "2026-10-06T17:24:16.61Z" } wheels = [ @@ -5877,8 +6010,8 @@ name = "universal-pathlib" version = "0.3.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "fsspec" }, - { name = "pathlib-abc" }, + { name = "fsspec", marker = "python_full_version < '3.15'" }, + { name = "pathlib-abc", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/6e/d997a70ee8f4c61f9a7e2f4f8af721cf072a3326848fc881b05187e52558/universal_pathlib-0.3.10.tar.gz", hash = "sha256:4487cbc90730a48cfb64f811d99e14b6faed6d738420cd5f93f59f48e6930bfb", size = 261110, upload-time = "2026-02-22T14:40:58.87Z" } wheels = [ @@ -6009,8 +6142,8 @@ name = "uvicorn" version = "0.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "h11" }, + { name = "click", marker = "python_full_version < '3.15'" }, + { name = "h11", marker = "python_full_version < '3.15'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } @@ -6020,13 +6153,13 @@ wheels = [ [package.optional-dependencies] standard = [ - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles" }, + { name = "httptools", marker = "python_full_version < '3.15'" }, + { name = "python-dotenv", marker = "python_full_version < '3.15'" }, + { name = "pyyaml", marker = "python_full_version < '3.15'" }, + { name = "uvloop", marker = "python_full_version < '3.15' and platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles", marker = "python_full_version < '3.15'" }, { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "websockets", version = "17.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "websockets", version = "17.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, ] [[package]] @@ -6119,6 +6252,7 @@ sqlalchemy = [ [package.dev-dependencies] dev = [ + { name = "coverage", extra = ["toml"] }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-httpserver" }, @@ -6145,6 +6279,7 @@ provides-extras = ["airflow", "dbt", "langchain", "llamaindex", "snowflake", "sq [package.metadata.requires-dev] dev = [ + { name = "coverage", extras = ["toml"], specifier = ">=7.10" }, { name = "mypy", specifier = ">=1.14" }, { name = "pytest", specifier = ">=8.3" }, { name = "pytest-httpserver", specifier = ">=1.1" }, @@ -6158,7 +6293,7 @@ name = "watchfiles" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" } wheels = [ @@ -6494,7 +6629,7 @@ name = "wirerope" version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "six", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8e/f1/d0a4c936ba77eb1050da6ea5e7cd80aa36add343d9e5f7f9cf79a206c5b8/wirerope-1.0.0.tar.gz", hash = "sha256:7da8bb6feeff9dd939bd7141ef0dc392674e43ba662e20909d6729db81a7c8d0", size = 10930, upload-time = "2025-01-16T11:01:25.427Z" } wheels = [ From ef4e620b69a1f6ad0d7725b49d9d5a3651fd185f Mon Sep 17 00:00:00 2001 From: Vericto <295450070+vericto@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:03:55 -0500 Subject: [PATCH 2/3] ci: keep the uv Dependabot entry out of ci/ and move the dbt job to pytest-httpserver 1.2.0 The uv updater also scans requirements files, so the uv entry for / bumped ci/requirements-airflow-3.txt past the Airflow 3.3.2 constraints file (apache-airflow-providers-common-sql 2.1.1 -> 2.2.0, PR #11) and duplicated the pip /ci entry's pytest-httpserver bump. exclude-paths leaves ci/ to the pip entry, whose apache-airflow* ignore keeps the pins in step with the constraints. After PR #10 the requirements files pin pytest-httpserver 1.2.0 (uv.lock already does); the dbt extras job's inline pin and the README's live-test container command still said 1.1.3. --- .github/dependabot.yml | 4 ++++ .github/workflows/ci.yml | 2 +- README.md | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a35a5de..597f6fb 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,8 +8,12 @@ updates: commit-message: prefix: ci + # The uv updater also scans requirements files; ci/ belongs to the pip entry + # below, whose rules keep the Airflow pins in step with the constraints file. - package-ecosystem: uv directory: / + exclude-paths: + - "ci/**" schedule: interval: monthly open-pull-requests-limit: 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05246b0..7f3eb64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,7 +120,7 @@ jobs: run: | uv venv --python "$PYTHON_VERSION" "$RUNNER_TEMP/extra" args=(--python "$RUNNER_TEMP/extra/bin/python" -e .) - if [ -n "$REQUIREMENTS" ]; then args+=(-r "$REQUIREMENTS"); else args+=(pytest==9.1.1 pytest-httpserver==1.1.3); fi + if [ -n "$REQUIREMENTS" ]; then args+=(-r "$REQUIREMENTS"); else args+=(pytest==9.1.1 pytest-httpserver==1.2.0); fi if [ -n "$CONSTRAINTS" ]; then args+=(-c "$CONSTRAINTS"); fi uv pip install "${args[@]}" - name: Test diff --git a/README.md b/README.md index c86627a..2f2f2d9 100644 --- a/README.md +++ b/README.md @@ -470,7 +470,7 @@ read-only and copied; the SQLite file never leaves the container): docker run --rm -v "$PWD":/src:ro \ -e VERICTO_LIVE_SQLITE=1 -e VERICTO_API_KEY -e VERICTO_API_URL -e VERICTO_DATABASE_ID \ python:3.13-slim sh -c 'cp -r /src /work && cd /work && rm -rf .venv \ - && pip install --quiet --no-cache-dir . pytest==9.1.1 pytest-httpserver==1.1.3 \ + && pip install --quiet --no-cache-dir . pytest==9.1.1 pytest-httpserver==1.2.0 \ && python -m pytest tests/live/test_sqlite_live.py -v -p no:cacheprovider' ``` From 35bb3ac89774c07751090ecb60438a4df9b75a09 Mon Sep 17 00:00:00 2001 From: Vericto <295450070+vericto@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:44:37 -0500 Subject: [PATCH 3/3] release: 0.3.1, sanitized mode follows vericto-cli 1.8.0 vericto.sanitize (what vericto-dbt sends from a workspace in "sanitized" telemetry mode) is a port of vericto-cli's src/sanitize.rs, but it never picked up vericto-cli 1.8.0 (5ed6ddd): - An existing bind parameter lost its meaning: the digits of $1 were read as a numeric literal, so "id = $1" was sent as "id = $$1", which opens a dollar-quoted string; the server evaluated different SQL or answered PARSE_ERROR. On Snowflake the same happened to stage columns ("SELECT $1 FROM @stage"). - Snowflake and SQLite got $1, $2, ... placeholders where vericto-cli uses ?. SQLite [name] identifiers were scanned (digits inside replaced), and $name was read as a dollar quote instead of a bind parameter. Ported rules, exactly as in sanitize.rs 1.8.0: - a digit after $ or ? is part of a bind parameter, not a literal; - ? placeholders for mysql, snowflake and sqlite; $N for postgres, oracle, mssql and any other dialect; - on sqlite: no dollar quoting, [name] copied verbatim. Tests: the two strict xfails pass and lose their markers. tests/fixtures/sanitize/vericto-cli-1.8.0.json holds all 19 assertions of sanitize.rs's tests at 1.8.0, copied verbatim with their source, and test_sanitize.py runs every one, so a future drift fails CI. New per-dialect cases (postgres, mysql, oracle, mssql, snowflake, sqlite) cover existing binds, dollar quotes, stage references and identifiers. The duplicated parity table in test_dbt.py (dbt job only) is replaced by the fixture; test_dbt.py checks a sanitized Snowflake model now gets ? and a sanitized PostgreSQL model keeps its $1. Release: _version.py 0.3.1, CHANGELOG [0.3.1] - 2026-10-18 (Fixed, plus the test/CI-gate entries that were under Unreleased). uv.lock does not record the project version (editable source), so it is unchanged. --- CHANGELOG.md | 31 ++++-- src/vericto/_version.py | 2 +- src/vericto/sanitize.py | 41 ++++++-- .../fixtures/sanitize/vericto-cli-1.8.0.json | 24 +++++ tests/test_dbt.py | 40 +++----- tests/test_sanitize.py | 96 +++++++++++++++---- 6 files changed, 177 insertions(+), 57 deletions(-) create mode 100644 tests/fixtures/sanitize/vericto-cli-1.8.0.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 639c330..008effb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ All notable changes to this package are documented here. The format follows ## [Unreleased] +## [0.3.1] - 2026-10-18 + +A bug-fix release: `vericto-dbt`'s sanitized mode follows vericto-cli 1.8.0 +again. Nothing else changes in the package. + +### Fixed + +- **Sanitized mode** (`vericto-dbt` in a workspace whose telemetry query mode is + `sanitized`): `vericto.sanitize` now applies vericto-cli 1.8.0's rules, so a + statement reads the same as with `vericto check`: + - Bind parameters already in the statement are kept. The digits of `$1` and + `?1` were read as a numeric literal, so `$1` (a PostgreSQL parameter, a + Snowflake stage column) became `$$1`, which opens a dollar-quoted string: + the server evaluated different SQL or answered `PARSE_ERROR`. + - Snowflake and SQLite get `?` placeholders, as MySQL does (they got `$1, $2, + ...`). On SQLite, `[name]` identifiers are copied as they are and `$name` + is a bind parameter, not a dollar quote. +- `tests/fixtures/sanitize/vericto-cli-1.8.0.json` pins vericto-cli's own + sanitizer test vectors (`src/sanitize.rs` at 1.8.0), and the port must give + the same output for each; per-dialect cases cover PostgreSQL, MySQL, Oracle, + MSSQL, Snowflake and SQLite. The two `xfail` tests for this bug now pass. + ### Added - Tests for the alternate flows: the Runtime API down, slow, rate-limited, over @@ -18,14 +40,6 @@ All notable changes to this package are documented here. The format follows - CI fails a job under 80% line and branch coverage (`coverage` in the dev group, `[tool.coverage]` in `pyproject.toml`); see README, "Coverage". -### Known issues - -- In a workspace in `sanitized` telemetry mode, `vericto-dbt` rewrites an - existing `$1` (a PostgreSQL bind parameter, a Snowflake stage column) as `$$1`, - which opens a dollar-quoted string, and uses `$N` placeholders for Snowflake - where vericto-cli 1.8.0 uses `?`. The port has not picked up vericto-cli - 1.8.0's sanitizer rules yet; two `xfail` tests in `tests/test_sanitize.py` track it. - ## [0.3.0] - 2026-10-18 A support-policy release: Python 3.9 and Airflow 2 are dropped. The API is @@ -152,6 +166,7 @@ the Vericto Runtime API (`POST /api/v1/proxy/query`). - The Vericto engine does not evaluate the `sqlite` dialect yet: the SQLite wrappers raise `VerictoUnsupportedDialect` until the server lists it. +[0.3.1]: https://github.com/vericto/vericto-python/releases/tag/v0.3.1 [0.3.0]: https://github.com/vericto/vericto-python/releases/tag/v0.3.0 [0.2.0]: https://github.com/vericto/vericto-python/releases/tag/v0.2.0 [0.1.0]: https://github.com/vericto/vericto-python/releases/tag/v0.1.0 diff --git a/src/vericto/_version.py b/src/vericto/_version.py index 493f741..260c070 100644 --- a/src/vericto/_version.py +++ b/src/vericto/_version.py @@ -1 +1 @@ -__version__ = "0.3.0" +__version__ = "0.3.1" diff --git a/src/vericto/sanitize.py b/src/vericto/sanitize.py index edf81b5..fba287d 100644 --- a/src/vericto/sanitize.py +++ b/src/vericto/sanitize.py @@ -2,23 +2,42 @@ When a Vericto workspace is in ``sanitized`` telemetry mode, literal values must not leave the machine: they are replaced by placeholders before the SQL is sent. -This is a port of vericto-cli's ``sanitize.rs`` (same rules, same output), so a -statement checked by ``vericto-dbt`` and by ``vericto check`` reads the same. +This is a port of vericto-cli's ``sanitize.rs`` as of vericto-cli 1.8.0 (same +rules, same output), so a statement checked by ``vericto-dbt`` and by +``vericto check`` reads the same. ``tests/fixtures/sanitize/vericto-cli-1.8.0.json`` +pins vericto-cli's own test vectors. Replaced: single-quoted string literals (``''`` escapes; backslash escapes in MySQL and in PostgreSQL ``E'...'`` strings) and numeric literals at a token boundary. Left intact: dollar-quoted blocks, identifiers, ``"quoted"`` and -backtick names, comments. Placeholders are ``?`` for MySQL and ``$1, $2, ...`` -elsewhere, so the result still parses. +backtick names, comments, and bind parameters the client already wrote +(``$1``, ``?1``, ``$name``). Placeholders are ``?`` for MySQL, Snowflake (where +``$1`` is a staged file's first column) and SQLite, and ``$1, $2, ...`` for +PostgreSQL, Oracle, MSSQL and any other dialect, so the result still parses. On +SQLite there is no dollar quoting (``$name`` is a bind parameter) and ``[name]`` +is an identifier, copied as it is. """ from __future__ import annotations +#: Dialects whose placeholder is ``?``; every other dialect gets ``$1, $2, ...``. +_QUESTION_MARK_DIALECTS = frozenset({"mysql", "snowflake", "sqlite"}) + def _is_ident(ch: str) -> bool: return ch.isascii() and (ch.isalnum() or ch == "_") +def _starts_literal(prev: str) -> bool: + """Whether a digit after ``prev`` (the last character written) starts a numeric literal. + + Not after an identifier character (``col2``), and not after ``$`` or ``?``: ``$1`` + (PostgreSQL, Snowflake, SQLite) and ``?1`` (SQLite) are bind parameters already in + the statement, and their digits are not a literal. + """ + return not (_is_ident(prev) or prev in ("$", "?")) + + def _dollar_tag(sql: str, start: int) -> tuple[int, str] | None: j = start + 1 while j < len(sql): @@ -34,7 +53,9 @@ def _dollar_tag(sql: str, start: int) -> tuple[int, str] | None: def sanitize(sql: str, dialect: str) -> str: """Return ``sql`` with string and numeric literals replaced by placeholders.""" - question = dialect == "mysql" + question = dialect in _QUESTION_MARK_DIALECTS + # SQLite: no dollar quoting (``$name`` is a bind parameter) and ``[name]`` is an identifier. + sqlite = dialect == "sqlite" counter = 0 out: list[str] = [] @@ -65,7 +86,7 @@ def last() -> str: out.append(sql[i:end]) i = end continue - if c == "$": + if c == "$" and not sqlite: tag = _dollar_tag(sql, i) if tag is not None: tag_end, name = tag @@ -78,6 +99,12 @@ def last() -> str: out.append("$") i += 1 continue + if sqlite and c == "[": + end = sql.find("]", i + 1) + end = n if end == -1 else end + 1 + out.append(sql[i:end]) + i = end + continue if c in ('"', "`"): end = sql.find(c, i + 1) end = n if end == -1 else end + 1 @@ -104,7 +131,7 @@ def last() -> str: i += 1 placeholder() continue - if c.isascii() and c.isdigit() and not _is_ident(last()): + if c.isascii() and c.isdigit() and _starts_literal(last()): i += 1 while i < n and ((sql[i].isascii() and sql[i].isdigit()) or sql[i] == "."): i += 1 diff --git a/tests/fixtures/sanitize/vericto-cli-1.8.0.json b/tests/fixtures/sanitize/vericto-cli-1.8.0.json new file mode 100644 index 0000000..0e06883 --- /dev/null +++ b/tests/fixtures/sanitize/vericto-cli-1.8.0.json @@ -0,0 +1,24 @@ +{ + "source": "vericto/vericto-cli src/sanitize.rs, mod tests, at commit 5ed6ddd (release 1.8.0). Every assert_eq!(sanitize(sql, dialect), expected) there, copied verbatim. Update this file together with any change to vericto-cli's sanitizer.", + "vectors": [ + {"test": "strips_string_literals_postgres_numbered", "dialect": "postgres", "sql": "SELECT * FROM users WHERE email = 'a@b.com'", "expected": "SELECT * FROM users WHERE email = $1"}, + {"test": "sqlite_uses_question_mark_and_keeps_its_identifiers", "dialect": "sqlite", "sql": "SELECT [it's], `a` FROM t WHERE x = $id AND y = 'O''Brien' AND z = 7", "expected": "SELECT [it's], `a` FROM t WHERE x = $id AND y = ? AND z = ?"}, + {"test": "sqlite_uses_question_mark_and_keeps_its_identifiers", "dialect": "sqlite", "sql": "WHERE p = 'C:\\' AND q = 'x'", "expected": "WHERE p = ? AND q = ?"}, + {"test": "existing_bind_parameters_are_kept", "dialect": "postgres", "sql": "SELECT a FROM t WHERE id = $1 AND name = $2", "expected": "SELECT a FROM t WHERE id = $1 AND name = $2"}, + {"test": "existing_bind_parameters_are_kept", "dialect": "sqlite", "sql": "SELECT a FROM t WHERE id = ?1 AND b = ?2 AND n = 5", "expected": "SELECT a FROM t WHERE id = ?1 AND b = ?2 AND n = ?"}, + {"test": "snowflake_uses_question_mark", "dialect": "snowflake", "sql": "SELECT $1 FROM @stage WHERE a = 'x'", "expected": "SELECT $1 FROM @stage WHERE a = ?"}, + {"test": "mysql_uses_question_mark", "dialect": "mysql", "sql": "SELECT * FROM users WHERE email = 'a@b.com'", "expected": "SELECT * FROM users WHERE email = ?"}, + {"test": "numbered_placeholders_increment", "dialect": "postgres", "sql": "WHERE a = 'x' AND b = 'y' AND c = 5", "expected": "WHERE a = $1 AND b = $2 AND c = $3"}, + {"test": "handles_escaped_quotes", "dialect": "postgres", "sql": "WHERE name = 'O''Brien'", "expected": "WHERE name = $1"}, + {"test": "strips_numbers_but_not_identifiers", "dialect": "postgres", "sql": "WHERE id = 42 AND col2 = 7", "expected": "WHERE id = $1 AND col2 = $2"}, + {"test": "leaves_dollar_quoted_blocks_intact", "dialect": "postgres", "sql": "DO $$ BEGIN PERFORM 'secret'; END $$", "expected": "DO $$ BEGIN PERFORM 'secret'; END $$"}, + {"test": "leaves_quoted_identifiers_intact", "dialect": "postgres", "sql": "SELECT \"user's col\" FROM t", "expected": "SELECT \"user's col\" FROM t"}, + {"test": "mysql_backslash_escaped_quote_does_not_leak", "dialect": "mysql", "sql": "WHERE note = 'it\\'s secret-token' AND id = 1", "expected": "WHERE note = ? AND id = ?"}, + {"test": "mysql_escaped_backslash_before_closing_quote", "dialect": "mysql", "sql": "WHERE p = 'C:\\\\' AND q = 'x'", "expected": "WHERE p = ? AND q = ?"}, + {"test": "postgres_e_string_backslash_escape_does_not_leak", "dialect": "postgres", "sql": "WHERE note = E'it\\'s secret-token' AND id = 1", "expected": "WHERE note = $1 AND id = $2"}, + {"test": "postgres_e_string_backslash_escape_does_not_leak", "dialect": "postgres", "sql": "WHERE note = e'a\\\\' AND b = 'y'", "expected": "WHERE note = $1 AND b = $2"}, + {"test": "postgres_standard_string_keeps_backslash_literal", "dialect": "postgres", "sql": "WHERE p = 'C:\\' AND q = 'x'", "expected": "WHERE p = $1 AND q = $2"}, + {"test": "identifier_ending_in_e_is_not_an_e_string", "dialect": "postgres", "sql": "SELECT name FROM t WHERE type='a'", "expected": "SELECT name FROM t WHERE type=$1"}, + {"test": "preserves_comments", "dialect": "postgres", "sql": "SELECT 1 -- note: keep 'this'\nFROM t", "expected": "SELECT $1 -- note: keep 'this'\nFROM t"} + ] +} diff --git a/tests/test_dbt.py b/tests/test_dbt.py index 28a412d..7ecf2de 100644 --- a/tests/test_dbt.py +++ b/tests/test_dbt.py @@ -17,7 +17,6 @@ from vericto import dbt from vericto.__main__ import main as module_main from vericto.ci_api import CiCheckClient, provenance_from_env -from vericto.sanitize import sanitize FIXTURE = Path(__file__).parent / "fixtures" / "dbt" / "jaffle" @@ -342,7 +341,8 @@ def test_sanitized_workspace_never_sends_literals(project: Path, client: CiCheck ), ) assert run(project, client, "--select", "customers", "--no-hooks") == 0 - assert ci.statements() == ["select customer_id from ANALYTICS.DBT.stg_customers where email = $1 and id = $2"] + # Snowflake placeholders are "?", as in vericto-cli 1.8.0 ($1 is a stage column there). + assert ci.statements() == ["select customer_id from ANALYTICS.DBT.stg_customers where email = ? and id = ?"] def test_config_unreadable_fails_unless_allowed(project: Path, client: CiCheckClient, ci: FakeCi) -> None: @@ -476,31 +476,21 @@ def test_client_repr_hides_the_key(ci: FakeCi) -> None: assert API_KEY not in repr(CiCheckClient(API_KEY, api_url=ci.url)) -# -- sanitizer (vericto-cli's sanitize.rs cases) ------------------------------------ +# The sanitizer itself, with vericto-cli's test vectors, is covered in tests/test_sanitize.py. -@pytest.mark.parametrize( - ("sql", "dialect", "expected"), - [ - ("SELECT * FROM users WHERE email = 'a@b.com'", "postgres", "SELECT * FROM users WHERE email = $1"), - ("SELECT * FROM users WHERE email = 'a@b.com'", "mysql", "SELECT * FROM users WHERE email = ?"), - ("WHERE a = 'x' AND b = 'y' AND c = 5", "postgres", "WHERE a = $1 AND b = $2 AND c = $3"), - ("WHERE name = 'O''Brien'", "postgres", "WHERE name = $1"), - ("WHERE id = 42 AND col2 = 7", "postgres", "WHERE id = $1 AND col2 = $2"), - ("DO $$ BEGIN PERFORM 'secret'; END $$", "postgres", "DO $$ BEGIN PERFORM 'secret'; END $$"), - ('SELECT "user\'s col" FROM t', "postgres", 'SELECT "user\'s col" FROM t'), - (r"WHERE note = 'it\'s secret-token' AND id = 1", "mysql", "WHERE note = ? AND id = ?"), - (r"WHERE p = 'C:\\' AND q = 'x'", "mysql", "WHERE p = ? AND q = ?"), - (r"WHERE note = E'it\'s secret-token' AND id = 1", "postgres", "WHERE note = $1 AND id = $2"), - (r"WHERE note = e'a\\' AND b = 'y'", "postgres", "WHERE note = $1 AND b = $2"), - (r"WHERE p = 'C:\' AND q = 'x'", "postgres", "WHERE p = $1 AND q = $2"), - ("SELECT name FROM t WHERE type='a'", "postgres", "SELECT name FROM t WHERE type=$1"), - ("SELECT 1 -- note: keep 'this'\nFROM t", "postgres", "SELECT $1 -- note: keep 'this'\nFROM t"), - ("SELECT /* 'x' */ a FROM `t1`", "mysql", "SELECT /* 'x' */ a FROM `t1`"), - ], -) -def test_sanitize_matches_vericto_cli(sql: str, dialect: str, expected: str) -> None: - assert sanitize(sql, dialect) == expected +def test_sanitized_postgres_workspace_keeps_bind_parameters(project: Path, client: CiCheckClient, ci: FakeCi) -> None: + ci.config["telemetry_query_mode"] = "sanitized" + edit_manifest( + project, + lambda m: m["nodes"]["model.jaffle.customers"].update( + compiled_code="select customer_id from stg_customers where id = $1 and email = 'ana@example.com'" + ), + ) + assert run(project, client, "--select", "customers", "--no-hooks", "--dialect", "postgres") == 0 + # The existing $1 stays $1 (it became "$$1", a dollar quote); the literal gets the first number, + # as vericto-cli numbers it. + assert ci.statements() == ["select customer_id from stg_customers where id = $1 and email = $1"] # -- manifest and selection edge cases ------------------------------------------------- diff --git a/tests/test_sanitize.py b/tests/test_sanitize.py index d39e032..b73c482 100644 --- a/tests/test_sanitize.py +++ b/tests/test_sanitize.py @@ -1,11 +1,86 @@ -"""vericto.sanitize edge cases beyond the vericto-cli parity table in test_dbt.py.""" +"""vericto.sanitize: vericto-cli's own test vectors, then per-dialect and edge cases.""" from __future__ import annotations +import json +from pathlib import Path +from typing import Any, Dict, List + import pytest from vericto.sanitize import sanitize +# vericto-cli's sanitize.rs test vectors (release 1.8.0), copied verbatim: the port must give the same +# output for each, so the two cannot drift apart unnoticed. See "source" in the file. +VERICTO_CLI_VECTORS = Path(__file__).parent / "fixtures" / "sanitize" / "vericto-cli-1.8.0.json" + + +def _vectors() -> List[Dict[str, Any]]: + data = json.loads(VERICTO_CLI_VECTORS.read_text(encoding="utf-8")) + vectors: List[Dict[str, Any]] = data["vectors"] + return vectors + + +@pytest.mark.parametrize("vector", _vectors(), ids=lambda v: f"{v['test']}-{v['dialect']}") +def test_matches_vericto_cli_test_vectors(vector: Dict[str, Any]) -> None: + assert sanitize(vector["sql"], vector["dialect"]) == vector["expected"] + + +def test_vericto_cli_vectors_cover_every_placeholder_style() -> None: + dialects = {v["dialect"] for v in _vectors()} + assert {"postgres", "mysql", "snowflake", "sqlite"} <= dialects + + +# Per dialect: placeholders, bind parameters already in the statement, dollar quotes, stage +# references and identifiers. Every expected value is also what vericto-cli 1.8.0 returns. +DIALECT_CASES = [ + # PostgreSQL: numbered placeholders; $N binds kept (the counter does not skip them, as in vericto-cli). + ("SELECT a FROM t WHERE id = $1 AND n = 5", "postgres", "SELECT a FROM t WHERE id = $1 AND n = $1"), + ("SELECT a FROM t WHERE id = $12", "postgres", "SELECT a FROM t WHERE id = $12"), + ("SELECT $body$ 'x' $body$, 'y'", "postgres", "SELECT $body$ 'x' $body$, $1"), + ('SELECT "col1", t1.c2 FROM t1 WHERE x = 3', "postgres", 'SELECT "col1", t1.c2 FROM t1 WHERE x = $1'), + # MySQL: ? placeholders; existing ? binds kept; backtick names verbatim. + ("SELECT a FROM t WHERE id = ? AND n = 5", "mysql", "SELECT a FROM t WHERE id = ? AND n = ?"), + ("SELECT `col 1`, `t2` FROM t WHERE x = 'a\\'b'", "mysql", "SELECT `col 1`, `t2` FROM t WHERE x = ?"), + ("SELECT $1 FROM t", "mysql", "SELECT $1 FROM t"), + # Oracle: numbered placeholders; :name binds kept. + ( + "SELECT 'a' || name FROM t WHERE id = :id AND n = 10", + "oracle", + "SELECT $1 || name FROM t WHERE id = :id AND n = $2", + ), + ("SELECT a FROM t WHERE id = $1", "oracle", "SELECT a FROM t WHERE id = $1"), + # MSSQL: numbered placeholders; @p1 binds kept. + ( + "SELECT [name] FROM t WHERE id = @p1 AND n = 3 AND s = 'x'", + "mssql", + "SELECT [name] FROM t WHERE id = @p1 AND n = $1 AND s = $2", + ), + # Snowflake: ? placeholders; $1 stage columns and ? binds kept; $$ strings verbatim. + ( + "SELECT $1, $2 FROM @stage/file.csv WHERE a = 5", + "snowflake", + "SELECT $1, $2 FROM @stage/file.csv WHERE a = ?", + ), + ("SELECT t.$1 FROM @s t WHERE id = ? AND n = 2", "snowflake", "SELECT t.$1 FROM @s t WHERE id = ? AND n = ?"), + ("SELECT $$it's$$, 'x'", "snowflake", "SELECT $$it's$$, ?"), + # SQLite: ? placeholders; [name] verbatim; :a, @b, $c, ?3 binds kept; $$ is not a dollar quote. + ( + "SELECT [col 1] FROM t WHERE a = :a AND b = @b AND c = $c AND d = ?3 AND e = 4", + "sqlite", + "SELECT [col 1] FROM t WHERE a = :a AND b = @b AND c = $c AND d = ?3 AND e = ?", + ), + ("SELECT $$ 'x' $$", "sqlite", "SELECT $$ ? $$"), + ("SELECT [open 1", "sqlite", "SELECT [open 1"), + # Any other dialect gets numbered placeholders, the safe default. + ("WHERE a = 'x' AND b = 2", "bigquery", "WHERE a = $1 AND b = $2"), +] + + +@pytest.mark.parametrize(("sql", "dialect", "expected"), DIALECT_CASES) +def test_dialects(sql: str, dialect: str, expected: str) -> None: + assert sanitize(sql, dialect) == expected + @pytest.mark.parametrize( ("sql", "dialect", "expected"), @@ -27,7 +102,9 @@ # An E prefix that is the end of an identifier is not an escape string. ("WHERE name = 'x' AND typE'y'", "postgres", "WHERE name = $1 AND typE$2"), # Backslashes are literal outside MySQL and E-strings. - ("WHERE p = 'a\\' AND q = 'b'", "snowflake", "WHERE p = $1 AND q = $2"), + ("WHERE p = 'a\\' AND q = 'b'", "snowflake", "WHERE p = ? AND q = ?"), + # Comments inside a MySQL statement stay as written. + ("SELECT /* 'x' */ a FROM `t1`", "mysql", "SELECT /* 'x' */ a FROM `t1`"), ("", "postgres", ""), ], ) @@ -49,25 +126,12 @@ def test_no_literal_survives_a_realistic_model() -> None: assert out.startswith("-- model: orders\n") -# vericto-cli 1.8.0 (sanitize.rs, 2026-10-15) changed two rules this port has not picked up yet. -# Both cases are copied from vericto-cli's own tests. - - -@pytest.mark.xfail( - strict=True, - reason="Port drift from vericto-cli 1.8.0: existing $N bind parameters become '$$N', which opens a " - "dollar-quoted string, so the evaluated text no longer matches the statement.", -) def test_existing_bind_parameters_are_kept() -> None: + # A $1 that became "$$1" opened a dollar-quoted string, so the server evaluated different SQL. assert sanitize("SELECT a FROM t WHERE id = $1 AND name = $2", "postgres") == ( "SELECT a FROM t WHERE id = $1 AND name = $2" ) -@pytest.mark.xfail( - strict=True, - reason="Port drift from vericto-cli 1.8.0: Snowflake placeholders are '?' there and '$N' here, and the " - "stage column reference $1 becomes '$$1'.", -) def test_snowflake_uses_question_marks_and_keeps_stage_columns() -> None: assert sanitize("SELECT $1 FROM @stage WHERE a = 'x'", "snowflake") == "SELECT $1 FROM @stage WHERE a = ?"