Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -110,15 +120,19 @@ 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
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:
Expand Down
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,40 @@ 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
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".

## [0.3.0] - 2026-10-18

A support-policy release: Python 3.9 and Airflow 2 are dropped. The API is
Expand Down Expand Up @@ -132,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
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -451,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'
```

Expand Down
12 changes: 12 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ dev = [
"mypy>=1.14",
"sqlalchemy>=2.0",
"snowflake-connector-python>=3.12",
"coverage[toml]>=7.10",
]

[tool.hatch.version]
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/vericto/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.3.0"
__version__ = "0.3.1"
41 changes: 34 additions & 7 deletions src/vericto/sanitize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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] = []

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions tests/fixtures/sanitize/vericto-cli-1.8.0.json
Original file line number Diff line number Diff line change
@@ -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"}
]
}
Loading
Loading