Skip to content

API Reference

Core

tollkeeper.Tollkeeper

Entry point for the write-audit-publish pattern.

Parameters:

Name Type Description Default
backend Backend

Storage backend (e.g. CsvBackend, IcebergBackend).

required

Example::

Tollkeeper(CsvBackend(staging, publish)).table("sales").write(fn).audit([NullCheck("id")]).publish()
Source code in src/tollkeeper/core.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
class Tollkeeper:
    """Entry point for the write-audit-publish pattern.

    Args:
        backend: Storage backend (e.g. ``CsvBackend``, ``IcebergBackend``).

    Example::

        Tollkeeper(CsvBackend(staging, publish)).table("sales").write(fn).audit([NullCheck("id")]).publish()
    """

    def __init__(self, backend: Backend, signal_store: SignalStore | None = None) -> None:
        self._backend = backend
        self._signal_store = signal_store

    def table(self, table: str) -> TollkeeperSession:
        version_ref = self._backend.create_version(table)
        return TollkeeperSession(self._backend, table, version_ref, self._signal_store)

tollkeeper.TollkeeperSession

A single write-audit-publish session for one table version.

Created by Tollkeeper.table(). Supports fluent chaining::

Tollkeeper(backend).table("sales").write(fn).audit([checks]).publish()
Source code in src/tollkeeper/core.py
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
class TollkeeperSession:
    """A single write-audit-publish session for one table version.

    Created by ``Tollkeeper.table()``. Supports fluent chaining::

        Tollkeeper(backend).table("sales").write(fn).audit([checks]).publish()
    """

    def __init__(self, backend: Backend, table: str, version_ref: str, signal_store: SignalStore | None = None) -> None:
        self._backend = backend
        self._table = table
        self._version_ref = version_ref
        self._report = CheckReport()
        self._published = False
        self._rolled_back = False
        self._audited = False
        self._signal_store = signal_store

    def __del__(self) -> None:
        if not self._published and not self._rolled_back:
            import logging

            logging.getLogger(__name__).warning(
                "TollkeeperSession for %s was never published or rolled back — rolling back", self._table
            )
            try:
                self._backend.rollback_version(self._table, self._version_ref)
            except Exception:
                pass
            self._rolled_back = True

    def __enter__(self) -> TollkeeperSession:
        return self

    def __exit__(self, exc_type: type | None, exc_val: BaseException | None, exc_tb: object) -> None:
        if not self._published and not self._rolled_back:
            try:
                self._backend.rollback_version(self._table, self._version_ref)
            except Exception:
                pass
            self._rolled_back = True

    @property
    def ref(self) -> str:
        return self._version_ref

    @property
    def report(self) -> CheckReport:
        return self._report

    def write(self, fn: Callable[[str], None]) -> TollkeeperSession:
        """Execute the write function against the staged version.

        Args:
            fn: Callable that receives the version reference and writes data to it.

        Raises:
            Exception: Re-raises any exception from ``fn`` after rolling back the staged version.
        """
        try:
            fn(self._version_ref)
        except Exception:
            self._backend.rollback_version(self._table, self._version_ref)
            self._rolled_back = True
            raise
        return self

    def audit(
        self,
        checks: list[BaseCheck],
        *,
        on_failure: str = "stop",
        on_notify: Callable[[str, str, list[CheckResult]], None] | None = None,
        execution_ctx: dict | None = None,
        conn: Any | None = None,
    ) -> TollkeeperSession:
        if on_failure not in ("stop", "continue"):
            raise ValueError("on_failure must be 'stop' or 'continue'")

        if self._signal_store:
            self._signal_store.delete(self._table, execution_ctx)

        self._report.results.extend(check.run(self._version_ref, conn=conn) for check in checks)
        self._audited = True

        if self._report.failed:
            if on_notify:
                on_notify(self._table, self._version_ref, self._report.failed)
            if on_failure == "stop":
                try:
                    self._backend.rollback_version(self._table, self._version_ref)
                finally:
                    self._rolled_back = True
                    raise AuditFailedError(self._table, self._version_ref, self._report.failed)

        if self._signal_store and self._report.passed:
            from datetime import datetime

            from tollkeeper.signals.base import Signal

            self._signal_store.write(
                Signal(
                    table_name=self._table,
                    execution_ctx=execution_ctx or {},
                    status="passed",
                    execution_ts=datetime.now(),
                    check_summary=f"{len(self._report.results)} checks passed",
                )
            )

        return self

    def publish(self) -> TollkeeperSession:
        """Promote the staged version to production.

        Raises:
            RuntimeError: If the session was already rolled back.
            RuntimeError: If ``audit()`` has not been called.
        """
        if self._rolled_back:
            raise RuntimeError("Cannot publish a rolled-back session")
        if not self._audited:
            raise RuntimeError("Cannot publish without running audit first")
        if not self._published:
            self._backend.publish_version(self._table, self._version_ref)
            self._published = True
        return self

    def rollback(self) -> TollkeeperSession:
        """Discard the staged version without publishing.

        Raises:
            RuntimeError: If the session was already published.
        """
        if self._published:
            raise RuntimeError("Cannot rollback a published session")
        if not self._rolled_back:
            self._backend.rollback_version(self._table, self._version_ref)
            self._rolled_back = True
        return self

publish()

Promote the staged version to production.

Raises:

Type Description
RuntimeError

If the session was already rolled back.

RuntimeError

If audit() has not been called.

Source code in src/tollkeeper/core.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def publish(self) -> TollkeeperSession:
    """Promote the staged version to production.

    Raises:
        RuntimeError: If the session was already rolled back.
        RuntimeError: If ``audit()`` has not been called.
    """
    if self._rolled_back:
        raise RuntimeError("Cannot publish a rolled-back session")
    if not self._audited:
        raise RuntimeError("Cannot publish without running audit first")
    if not self._published:
        self._backend.publish_version(self._table, self._version_ref)
        self._published = True
    return self

rollback()

Discard the staged version without publishing.

Raises:

Type Description
RuntimeError

If the session was already published.

Source code in src/tollkeeper/core.py
170
171
172
173
174
175
176
177
178
179
180
181
def rollback(self) -> TollkeeperSession:
    """Discard the staged version without publishing.

    Raises:
        RuntimeError: If the session was already published.
    """
    if self._published:
        raise RuntimeError("Cannot rollback a published session")
    if not self._rolled_back:
        self._backend.rollback_version(self._table, self._version_ref)
        self._rolled_back = True
    return self

write(fn)

Execute the write function against the staged version.

Parameters:

Name Type Description Default
fn Callable[[str], None]

Callable that receives the version reference and writes data to it.

required

Raises:

Type Description
Exception

Re-raises any exception from fn after rolling back the staged version.

Source code in src/tollkeeper/core.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
def write(self, fn: Callable[[str], None]) -> TollkeeperSession:
    """Execute the write function against the staged version.

    Args:
        fn: Callable that receives the version reference and writes data to it.

    Raises:
        Exception: Re-raises any exception from ``fn`` after rolling back the staged version.
    """
    try:
        fn(self._version_ref)
    except Exception:
        self._backend.rollback_version(self._table, self._version_ref)
        self._rolled_back = True
        raise
    return self

tollkeeper.CheckReport dataclass

Aggregated results from an audit pass.

Attributes:

Name Type Description
results list[CheckResult]

List of individual CheckResult objects.

Source code in src/tollkeeper/core.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@dataclass
class CheckReport:
    """Aggregated results from an audit pass.

    Attributes:
        results: List of individual ``CheckResult`` objects.
    """

    results: list[CheckResult] = field(default_factory=list)

    @property
    def passed(self) -> bool:
        return all(r.passed for r in self.results)

    @property
    def failed(self) -> list[CheckResult]:
        return [r for r in self.results if not r.passed]

tollkeeper.AuditFailedError

Bases: Exception

Raised when a hard DQ audit (on_failure="stop") finds violations.

Source code in src/tollkeeper/core.py
31
32
33
34
35
36
37
38
39
class AuditFailedError(Exception):
    """Raised when a hard DQ audit (``on_failure="stop"``) finds violations."""

    def __init__(self, table: str, version_ref: str, failed: list[CheckResult]) -> None:
        self.table = table
        self.version_ref = version_ref
        self.failed = failed
        names = ", ".join(r.check_name for r in failed)
        super().__init__(f"Audit failed for {table}@{version_ref}: {names}")

Backends

tollkeeper.backends.base.Backend

Bases: ABC

Abstract base for Tollkeeper storage backends.

A backend manages the lifecycle of a versioned table: creating an isolated staging area, promoting it to production, or rolling it back. Implement this to add support for a new storage layer (CSV files, Iceberg, Delta, etc.).

Source code in src/tollkeeper/backends/base.py
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class Backend(ABC):
    """Abstract base for Tollkeeper storage backends.

    A backend manages the lifecycle of a versioned table: creating an isolated
    staging area, promoting it to production, or rolling it back. Implement this
    to add support for a new storage layer (CSV files, Iceberg, Delta, etc.).
    """

    @abstractmethod
    def create_version(self, table: str) -> str:
        """Create an isolated staging version and return a version reference.

        The reference is backend-specific: a file path, branch name, snapshot ID, etc.
        The caller writes data to whatever the reference points at.

        Args:
            table: Logical table name (e.g. ``"sales"``).

        Returns:
            An opaque version reference string passed to subsequent methods.
        """

    @abstractmethod
    def publish_version(self, table: str, version_ref: str) -> None:
        """Promote the staged version to production.

        Args:
            table: Logical table name.
            version_ref: Reference returned by ``create_version``.
        """

    @abstractmethod
    def rollback_version(self, table: str, version_ref: str) -> None:
        """Discard a staged version without publishing.

        Args:
            table: Logical table name.
            version_ref: Reference returned by ``create_version``.
        """

create_version(table) abstractmethod

Create an isolated staging version and return a version reference.

The reference is backend-specific: a file path, branch name, snapshot ID, etc. The caller writes data to whatever the reference points at.

Parameters:

Name Type Description Default
table str

Logical table name (e.g. "sales").

required

Returns:

Type Description
str

An opaque version reference string passed to subsequent methods.

Source code in src/tollkeeper/backends/base.py
14
15
16
17
18
19
20
21
22
23
24
25
26
@abstractmethod
def create_version(self, table: str) -> str:
    """Create an isolated staging version and return a version reference.

    The reference is backend-specific: a file path, branch name, snapshot ID, etc.
    The caller writes data to whatever the reference points at.

    Args:
        table: Logical table name (e.g. ``"sales"``).

    Returns:
        An opaque version reference string passed to subsequent methods.
    """

publish_version(table, version_ref) abstractmethod

Promote the staged version to production.

Parameters:

Name Type Description Default
table str

Logical table name.

required
version_ref str

Reference returned by create_version.

required
Source code in src/tollkeeper/backends/base.py
28
29
30
31
32
33
34
35
@abstractmethod
def publish_version(self, table: str, version_ref: str) -> None:
    """Promote the staged version to production.

    Args:
        table: Logical table name.
        version_ref: Reference returned by ``create_version``.
    """

rollback_version(table, version_ref) abstractmethod

Discard a staged version without publishing.

Parameters:

Name Type Description Default
table str

Logical table name.

required
version_ref str

Reference returned by create_version.

required
Source code in src/tollkeeper/backends/base.py
37
38
39
40
41
42
43
44
@abstractmethod
def rollback_version(self, table: str, version_ref: str) -> None:
    """Discard a staged version without publishing.

    Args:
        table: Logical table name.
        version_ref: Reference returned by ``create_version``.
    """

tollkeeper.backends.csv.CsvBackend

Bases: Backend

Tollkeeper backend for local CSV files.

Stages data as a temporary CSV in staging_dir, then copies to publish_dir on publish or deletes on rollback.

Parameters:

Name Type Description Default
staging_dir Path

Directory for temporary staging files.

required
publish_dir Path

Directory where final published CSVs land.

required

Example::

backend = CsvBackend(staging_dir=Path("/tmp/tollkeeper"), publish_dir=Path("/data/output"))
Tollkeeper(backend).table("sales").write(lambda ref: shutil.copy(src, ref)).audit([...]).publish()
Source code in src/tollkeeper/backends/csv.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
class CsvBackend(Backend):
    """Tollkeeper backend for local CSV files.

    Stages data as a temporary CSV in ``staging_dir``, then copies to
    ``publish_dir`` on publish or deletes on rollback.

    Args:
        staging_dir: Directory for temporary staging files.
        publish_dir: Directory where final published CSVs land.

    Example::

        backend = CsvBackend(staging_dir=Path("/tmp/tollkeeper"), publish_dir=Path("/data/output"))
        Tollkeeper(backend).table("sales").write(lambda ref: shutil.copy(src, ref)).audit([...]).publish()
    """

    def __init__(self, staging_dir: Path, publish_dir: Path) -> None:
        self._staging_dir = staging_dir
        self._publish_dir = publish_dir

    def create_version(self, table: str) -> str:
        self._staging_dir.mkdir(parents=True, exist_ok=True)
        staging_path = self._staging_dir / f"{table}.tollkeeper-{uuid4().hex[:8]}.csv"
        return str(staging_path)

    def publish_version(self, table: str, version_ref: str) -> None:
        self._publish_dir.mkdir(parents=True, exist_ok=True)
        _move(Path(version_ref), self._publish_dir / f"{table}.csv")

    def rollback_version(self, table: str, version_ref: str) -> None:
        Path(version_ref).unlink(missing_ok=True)

    def cleanup_staging(self, max_age_seconds: float = 3600) -> list[Path]:
        """Remove Tollkeeper staging files older than ``max_age_seconds``.

        Args:
            max_age_seconds: Maximum age in seconds before a staging file is considered orphaned.

        Returns:
            List of removed file paths.
        """
        if not self._staging_dir.exists():
            return []
        cutoff = time.time() - max_age_seconds
        removed: list[Path] = []
        for p in self._staging_dir.glob("*.tollkeeper-*.csv"):
            if p.stat().st_mtime < cutoff:
                p.unlink()
                removed.append(p)
        return removed

cleanup_staging(max_age_seconds=3600)

Remove Tollkeeper staging files older than max_age_seconds.

Parameters:

Name Type Description Default
max_age_seconds float

Maximum age in seconds before a staging file is considered orphaned.

3600

Returns:

Type Description
list[Path]

List of removed file paths.

Source code in src/tollkeeper/backends/csv.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def cleanup_staging(self, max_age_seconds: float = 3600) -> list[Path]:
    """Remove Tollkeeper staging files older than ``max_age_seconds``.

    Args:
        max_age_seconds: Maximum age in seconds before a staging file is considered orphaned.

    Returns:
        List of removed file paths.
    """
    if not self._staging_dir.exists():
        return []
    cutoff = time.time() - max_age_seconds
    removed: list[Path] = []
    for p in self._staging_dir.glob("*.tollkeeper-*.csv"):
        if p.stat().st_mtime < cutoff:
            p.unlink()
            removed.append(p)
    return removed

tollkeeper.backends.iceberg.IcebergBackend

Bases: Backend

Tollkeeper backend for Apache Iceberg tables using branch-based isolation.

Creates a temporary branch for staging writes, then fast-forwards main on publish or drops the branch on rollback.

Parameters:

Name Type Description Default
catalog Catalog

A PyIceberg Catalog instance.

required

Example::

from pyiceberg.catalog.sql import SqlCatalog
catalog = SqlCatalog("default", warehouse="/tmp/warehouse", uri="sqlite:///catalog.db")
backend = IcebergBackend(catalog)
Tollkeeper(backend).table("db.sales").write(lambda ref: table.append(df, branch=ref)).audit([...]).publish()
Source code in src/tollkeeper/backends/iceberg.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
class IcebergBackend(Backend):
    """Tollkeeper backend for Apache Iceberg tables using branch-based isolation.

    Creates a temporary branch for staging writes, then fast-forwards ``main``
    on publish or drops the branch on rollback.

    Args:
        catalog: A PyIceberg ``Catalog`` instance.

    Example::

        from pyiceberg.catalog.sql import SqlCatalog
        catalog = SqlCatalog("default", warehouse="/tmp/warehouse", uri="sqlite:///catalog.db")
        backend = IcebergBackend(catalog)
        Tollkeeper(backend).table("db.sales").write(lambda ref: table.append(df, branch=ref)).audit([...]).publish()
    """

    def __init__(self, catalog: Catalog) -> None:
        self._catalog = catalog

    def create_version(self, table: str) -> str:
        """Create a Tollkeeper branch on the Iceberg table.

        Args:
            table: Fully qualified Iceberg table identifier (e.g. ``"db.sales"``).

        Returns:
            Branch name (e.g. ``"tollkeeper-a1b2c3d4"``). Pass this as the ``branch``
            parameter when writing via PyIceberg.
        """
        iceberg_table = self._catalog.load_table(table)
        branch_name = f"tollkeeper-{uuid4().hex[:8]}"
        snapshot = iceberg_table.current_snapshot()
        if snapshot is None:
            raise RuntimeError(f"Table {table} has no snapshots — write initial data before using Tollkeeper")
        iceberg_table.manage_snapshots().create_branch(snapshot.snapshot_id, branch_name).commit()
        return branch_name

    def publish_version(self, table: str, version_ref: str) -> None:
        """Fast-forward ``main`` to the branch snapshot, then remove the branch.

        Args:
            table: Fully qualified Iceberg table identifier.
            version_ref: Branch name returned by ``create_version``.
        """
        iceberg_table = self._catalog.load_table(table)
        iceberg_table.manage_snapshots().set_current_snapshot(ref_name=version_ref).commit()
        iceberg_table.manage_snapshots().remove_branch(version_ref).commit()

    def rollback_version(self, table: str, version_ref: str) -> None:
        """Drop the Tollkeeper branch without publishing.

        Args:
            table: Fully qualified Iceberg table identifier.
            version_ref: Branch name returned by ``create_version``.
        """
        iceberg_table = self._catalog.load_table(table)
        try:
            iceberg_table.manage_snapshots().remove_branch(version_ref).commit()
        except Exception:
            pass

create_version(table)

Create a Tollkeeper branch on the Iceberg table.

Parameters:

Name Type Description Default
table str

Fully qualified Iceberg table identifier (e.g. "db.sales").

required

Returns:

Type Description
str

Branch name (e.g. "tollkeeper-a1b2c3d4"). Pass this as the branch

str

parameter when writing via PyIceberg.

Source code in src/tollkeeper/backends/iceberg.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
def create_version(self, table: str) -> str:
    """Create a Tollkeeper branch on the Iceberg table.

    Args:
        table: Fully qualified Iceberg table identifier (e.g. ``"db.sales"``).

    Returns:
        Branch name (e.g. ``"tollkeeper-a1b2c3d4"``). Pass this as the ``branch``
        parameter when writing via PyIceberg.
    """
    iceberg_table = self._catalog.load_table(table)
    branch_name = f"tollkeeper-{uuid4().hex[:8]}"
    snapshot = iceberg_table.current_snapshot()
    if snapshot is None:
        raise RuntimeError(f"Table {table} has no snapshots — write initial data before using Tollkeeper")
    iceberg_table.manage_snapshots().create_branch(snapshot.snapshot_id, branch_name).commit()
    return branch_name

publish_version(table, version_ref)

Fast-forward main to the branch snapshot, then remove the branch.

Parameters:

Name Type Description Default
table str

Fully qualified Iceberg table identifier.

required
version_ref str

Branch name returned by create_version.

required
Source code in src/tollkeeper/backends/iceberg.py
48
49
50
51
52
53
54
55
56
57
def publish_version(self, table: str, version_ref: str) -> None:
    """Fast-forward ``main`` to the branch snapshot, then remove the branch.

    Args:
        table: Fully qualified Iceberg table identifier.
        version_ref: Branch name returned by ``create_version``.
    """
    iceberg_table = self._catalog.load_table(table)
    iceberg_table.manage_snapshots().set_current_snapshot(ref_name=version_ref).commit()
    iceberg_table.manage_snapshots().remove_branch(version_ref).commit()

rollback_version(table, version_ref)

Drop the Tollkeeper branch without publishing.

Parameters:

Name Type Description Default
table str

Fully qualified Iceberg table identifier.

required
version_ref str

Branch name returned by create_version.

required
Source code in src/tollkeeper/backends/iceberg.py
59
60
61
62
63
64
65
66
67
68
69
70
def rollback_version(self, table: str, version_ref: str) -> None:
    """Drop the Tollkeeper branch without publishing.

    Args:
        table: Fully qualified Iceberg table identifier.
        version_ref: Branch name returned by ``create_version``.
    """
    iceberg_table = self._catalog.load_table(table)
    try:
        iceberg_table.manage_snapshots().remove_branch(version_ref).commit()
    except Exception:
        pass

Checks

tollkeeper.checks.base.BaseCheck

Bases: ABC

Abstract base for data quality checks.

Subclass this to create checks using any engine (Polars, Pandas, Presto, etc.). The run method receives a version reference (e.g. a file path or table name) and returns a CheckResult.

Source code in src/tollkeeper/checks/base.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class BaseCheck(ABC):
    """Abstract base for data quality checks.

    Subclass this to create checks using any engine (Polars, Pandas, Presto, etc.).
    The ``run`` method receives a version reference (e.g. a file path or table name)
    and returns a ``CheckResult``.
    """

    @property
    def name(self) -> str:
        return self.__class__.__name__

    @abstractmethod
    def run(self, version_ref: str, *, conn: Any | None = None) -> CheckResult:
        """Execute the check against the given version reference.

        Args:
            version_ref: Backend-specific reference to the staged data.
            conn: Optional engine connection for remote check execution.
                When ``None``, checks run in-process.

        Returns:
            A ``CheckResult`` indicating pass/fail with details.
        """

run(version_ref, *, conn=None) abstractmethod

Execute the check against the given version reference.

Parameters:

Name Type Description Default
version_ref str

Backend-specific reference to the staged data.

required
conn Any | None

Optional engine connection for remote check execution. When None, checks run in-process.

None

Returns:

Type Description
CheckResult

A CheckResult indicating pass/fail with details.

Source code in src/tollkeeper/checks/base.py
35
36
37
38
39
40
41
42
43
44
45
46
@abstractmethod
def run(self, version_ref: str, *, conn: Any | None = None) -> CheckResult:
    """Execute the check against the given version reference.

    Args:
        version_ref: Backend-specific reference to the staged data.
        conn: Optional engine connection for remote check execution.
            When ``None``, checks run in-process.

    Returns:
        A ``CheckResult`` indicating pass/fail with details.
    """

tollkeeper.checks.base.CheckResult dataclass

Result of a single DQ check execution.

Attributes:

Name Type Description
check_name str

Name of the check that produced this result.

passed bool

Whether the check passed.

details str

Human-readable details (e.g. "3 nulls in 'id'").

Source code in src/tollkeeper/checks/base.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@dataclass
class CheckResult:
    """Result of a single DQ check execution.

    Attributes:
        check_name: Name of the check that produced this result.
        passed: Whether the check passed.
        details: Human-readable details (e.g. ``"3 nulls in 'id'"``).
    """

    check_name: str
    passed: bool
    details: str = ""

tollkeeper.checks.polars.NullCheck

Bases: BaseCheck

Fails if the specified column contains any null values.

Parameters:

Name Type Description Default
column str

Column name to check for nulls.

required
Source code in src/tollkeeper/checks/polars.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class NullCheck(BaseCheck):
    """Fails if the specified column contains any null values.

    Args:
        column: Column name to check for nulls.
    """

    def __init__(self, column: str) -> None:
        self._column = column

    def run(self, version_ref: str, *, conn: Any | None = None) -> CheckResult:
        df = pl.scan_csv(version_ref).collect()
        null_count = df[self._column].null_count()
        return CheckResult(
            check_name=self.name,
            passed=null_count == 0,
            details=f"{null_count} nulls in '{self._column}'",
        )

tollkeeper.checks.polars.RowCountCheck

Bases: BaseCheck

Fails if the row count is below the minimum threshold.

Parameters:

Name Type Description Default
min_rows int

Minimum number of rows required to pass.

required
Source code in src/tollkeeper/checks/polars.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class RowCountCheck(BaseCheck):
    """Fails if the row count is below the minimum threshold.

    Args:
        min_rows: Minimum number of rows required to pass.
    """

    def __init__(self, min_rows: int) -> None:
        self._min_rows = min_rows

    def run(self, version_ref: str, *, conn: Any | None = None) -> CheckResult:
        df = pl.scan_csv(version_ref).collect()
        count = len(df)
        return CheckResult(
            check_name=self.name,
            passed=count >= self._min_rows,
            details=f"{count} rows, minimum {self._min_rows}",
        )

tollkeeper.checks.polars.ExpressionCheck

Bases: BaseCheck

Fails if any row does not satisfy a Polars expression.

Parameters:

Name Type Description Default
name str

Check name (used in reports).

required
expr Expr

A Polars expression that evaluates to boolean per row.

required

Example::

ExpressionCheck("positive_age", pl.col("age") > 0)
Source code in src/tollkeeper/checks/polars.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
class ExpressionCheck(BaseCheck):
    """Fails if any row does not satisfy a Polars expression.

    Args:
        name: Check name (used in reports).
        expr: A Polars expression that evaluates to boolean per row.

    Example::

        ExpressionCheck("positive_age", pl.col("age") > 0)
    """

    def __init__(self, name: str, expr: pl.Expr) -> None:
        self._name = name
        self._expr = expr

    @property
    def name(self) -> str:
        return self._name

    def run(self, version_ref: str, *, conn: Any | None = None) -> CheckResult:
        df = pl.scan_csv(version_ref).collect()
        violations = df.filter(~self._expr)
        return CheckResult(
            check_name=self.name,
            passed=len(violations) == 0,
            details=f"{len(violations)} rows violate '{self._name}'",
        )

tollkeeper.checks.polars.SqlCheck

Bases: BaseCheck

Fails if any row does not satisfy a SQL WHERE condition.

Uses Polars SQLContext to evaluate the condition. The staged data is registered as a table named data.

Parameters:

Name Type Description Default
name str

Check name (used in reports).

required
condition str

SQL WHERE clause (e.g. "age > 0 AND name IS NOT NULL").

required

Example::

SqlCheck("valid_age", "age > 0 AND age < 150")
Source code in src/tollkeeper/checks/polars.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
class SqlCheck(BaseCheck):
    """Fails if any row does not satisfy a SQL WHERE condition.

    Uses Polars ``SQLContext`` to evaluate the condition. The staged data
    is registered as a table named ``data``.

    Args:
        name: Check name (used in reports).
        condition: SQL WHERE clause (e.g. ``"age > 0 AND name IS NOT NULL"``).

    Example::

        SqlCheck("valid_age", "age > 0 AND age < 150")
    """

    def __init__(self, name: str, condition: str) -> None:
        self._name = name
        self._condition = condition

    @property
    def name(self) -> str:
        return self._name

    def run(self, version_ref: str, *, conn: Any | None = None) -> CheckResult:
        df = pl.scan_csv(version_ref).collect()
        ctx = pl.SQLContext({"data": df})
        violations = ctx.execute(f"SELECT * FROM data WHERE NOT ({self._condition})").collect()  # nosec B608 - Polars in-memory SQL, not a real DB
        return CheckResult(
            check_name=self.name,
            passed=len(violations) == 0,
            details=f"{len(violations)} rows violate '{self._name}'",
        )

tollkeeper.checks.polars.UniqueCheck

Bases: BaseCheck

Fails if any combination of the given columns has duplicate rows.

Parameters:

Name Type Description Default
columns list[str]

List of column names that should form a unique key.

required

Example::

UniqueCheck(["region", "date"])
Source code in src/tollkeeper/checks/polars.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
class UniqueCheck(BaseCheck):
    """Fails if any combination of the given columns has duplicate rows.

    Args:
        columns: List of column names that should form a unique key.

    Example::

        UniqueCheck(["region", "date"])
    """

    def __init__(self, columns: list[str]) -> None:
        self._columns = columns

    def run(self, version_ref: str, *, conn: Any | None = None) -> CheckResult:
        df = pl.scan_csv(version_ref).collect()
        duplicates = df.group_by(self._columns).len().filter(pl.col("len") > 1)
        return CheckResult(
            check_name=self.name,
            passed=len(duplicates) == 0,
            details=f"{len(duplicates)} duplicate groups on {self._columns}",
        )

Signals

tollkeeper.signals.base.Signal dataclass

A record indicating a table's data has been audited.

Source code in src/tollkeeper/signals/base.py
22
23
24
25
26
27
28
29
30
31
@dataclass
class Signal:
    """A record indicating a table's data has been audited."""

    table_name: str
    execution_ctx: dict = field(default_factory=dict)
    status: str = "passed"
    execution_ts: datetime = field(default_factory=datetime.now)
    check_summary: str = ""
    metadata: dict = field(default_factory=dict)

tollkeeper.signals.base.SignalStore

Bases: ABC

Abstract base for signal stores that track audit completion.

Source code in src/tollkeeper/signals/base.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
class SignalStore(ABC):
    """Abstract base for signal stores that track audit completion."""

    def __init__(self, *, on_delete_callback: Callable[[str, str, dict], None] | None = None) -> None:
        self._on_delete_callback = on_delete_callback

    @abstractmethod
    def write(self, signal: Signal) -> None:
        """UPSERT signal row. Creates or overwrites."""

    @abstractmethod
    def delete(self, table: str, execution_ctx: dict | None = None) -> None:
        """Delete signal and process downstream cascade/notify."""

    @abstractmethod
    def check(self, table: str, execution_ctx: dict | None = None) -> Signal | None:
        """Return signal if it exists, else None."""

    def wait(
        self,
        table: str,
        execution_ctx: dict | None = None,
        *,
        timeout_s: float = 300,
        poll_s: float = 10,
    ) -> Signal:
        """Block until signal appears or timeout."""
        deadline = time.monotonic() + timeout_s
        while time.monotonic() < deadline:
            signal = self.check(table, execution_ctx)
            if signal is not None:
                return signal
            time.sleep(poll_s)
        raise TimeoutError(f"No signal for {table} within {timeout_s}s")

    @abstractmethod
    def write_dq_result(self, result: DqResult) -> None:
        """UPSERT a DQ check result."""

    @abstractmethod
    def get_dq_results(self, table: str, execution_ctx: dict | None = None) -> list[DqResult]:
        """Return all DQ results for a table/execution."""

    @abstractmethod
    def delete_dq_results(self, table: str, execution_ctx: dict | None = None) -> None:
        """Delete all DQ results for a table/execution (before re-running checks)."""

    @abstractmethod
    def register_dep(
        self,
        upstream_table: str,
        downstream_table: str,
        cascade_policy: str = "notify",
        upstream_ctx: dict | None = None,
        downstream_ctx: dict | None = None,
    ) -> None:
        """Declare that downstream depends on upstream."""

    @abstractmethod
    def get_downstream(self, table: str, execution_ctx: dict | None = None) -> list[tuple[str, dict, str]]:
        """Return (table, ctx, cascade_policy) for all downstream dependents."""

    def close(self) -> None:
        """Release underlying resources. Override in subclasses that hold connections."""

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        self.close()

check(table, execution_ctx=None) abstractmethod

Return signal if it exists, else None.

Source code in src/tollkeeper/signals/base.py
48
49
50
@abstractmethod
def check(self, table: str, execution_ctx: dict | None = None) -> Signal | None:
    """Return signal if it exists, else None."""

close()

Release underlying resources. Override in subclasses that hold connections.

Source code in src/tollkeeper/signals/base.py
96
97
def close(self) -> None:
    """Release underlying resources. Override in subclasses that hold connections."""

delete(table, execution_ctx=None) abstractmethod

Delete signal and process downstream cascade/notify.

Source code in src/tollkeeper/signals/base.py
44
45
46
@abstractmethod
def delete(self, table: str, execution_ctx: dict | None = None) -> None:
    """Delete signal and process downstream cascade/notify."""

delete_dq_results(table, execution_ctx=None) abstractmethod

Delete all DQ results for a table/execution (before re-running checks).

Source code in src/tollkeeper/signals/base.py
77
78
79
@abstractmethod
def delete_dq_results(self, table: str, execution_ctx: dict | None = None) -> None:
    """Delete all DQ results for a table/execution (before re-running checks)."""

get_downstream(table, execution_ctx=None) abstractmethod

Return (table, ctx, cascade_policy) for all downstream dependents.

Source code in src/tollkeeper/signals/base.py
92
93
94
@abstractmethod
def get_downstream(self, table: str, execution_ctx: dict | None = None) -> list[tuple[str, dict, str]]:
    """Return (table, ctx, cascade_policy) for all downstream dependents."""

get_dq_results(table, execution_ctx=None) abstractmethod

Return all DQ results for a table/execution.

Source code in src/tollkeeper/signals/base.py
73
74
75
@abstractmethod
def get_dq_results(self, table: str, execution_ctx: dict | None = None) -> list[DqResult]:
    """Return all DQ results for a table/execution."""

register_dep(upstream_table, downstream_table, cascade_policy='notify', upstream_ctx=None, downstream_ctx=None) abstractmethod

Declare that downstream depends on upstream.

Source code in src/tollkeeper/signals/base.py
81
82
83
84
85
86
87
88
89
90
@abstractmethod
def register_dep(
    self,
    upstream_table: str,
    downstream_table: str,
    cascade_policy: str = "notify",
    upstream_ctx: dict | None = None,
    downstream_ctx: dict | None = None,
) -> None:
    """Declare that downstream depends on upstream."""

wait(table, execution_ctx=None, *, timeout_s=300, poll_s=10)

Block until signal appears or timeout.

Source code in src/tollkeeper/signals/base.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def wait(
    self,
    table: str,
    execution_ctx: dict | None = None,
    *,
    timeout_s: float = 300,
    poll_s: float = 10,
) -> Signal:
    """Block until signal appears or timeout."""
    deadline = time.monotonic() + timeout_s
    while time.monotonic() < deadline:
        signal = self.check(table, execution_ctx)
        if signal is not None:
            return signal
        time.sleep(poll_s)
    raise TimeoutError(f"No signal for {table} within {timeout_s}s")

write(signal) abstractmethod

UPSERT signal row. Creates or overwrites.

Source code in src/tollkeeper/signals/base.py
40
41
42
@abstractmethod
def write(self, signal: Signal) -> None:
    """UPSERT signal row. Creates or overwrites."""

write_dq_result(result) abstractmethod

UPSERT a DQ check result.

Source code in src/tollkeeper/signals/base.py
69
70
71
@abstractmethod
def write_dq_result(self, result: DqResult) -> None:
    """UPSERT a DQ check result."""

tollkeeper.signals.sqlite.SqliteSignalStore

Bases: SignalStore

Signal store backed by a SQLite database.

Source code in src/tollkeeper/signals/sqlite.py
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
class SqliteSignalStore(SignalStore):
    """Signal store backed by a SQLite database."""

    def __init__(
        self,
        db_path: str | Path = ":memory:",
        *,
        on_delete_callback: Callable[[str, str, dict], None] | None = None,
    ) -> None:
        super().__init__(on_delete_callback=on_delete_callback)
        self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
        self._conn.row_factory = sqlite3.Row
        self._init_schema()

    def _init_schema(self) -> None:
        self._conn.executescript("""
            CREATE TABLE IF NOT EXISTS tollkeeper_signals (
                table_name    TEXT NOT NULL,
                execution_ctx TEXT NOT NULL DEFAULT '{}',
                status        TEXT NOT NULL,
                execution_ts  TEXT NOT NULL,
                updated_at    TEXT NOT NULL,
                check_summary TEXT DEFAULT '',
                metadata      TEXT DEFAULT '{}',
                PRIMARY KEY (table_name, execution_ctx)
            );
            CREATE TABLE IF NOT EXISTS tollkeeper_dq_results (
                table_name    TEXT NOT NULL,
                execution_ctx TEXT NOT NULL DEFAULT '{}',
                check_name    TEXT NOT NULL,
                passed        INTEGER NOT NULL,
                details       TEXT DEFAULT '',
                executed_at   TEXT NOT NULL,
                PRIMARY KEY (table_name, execution_ctx, check_name)
            );
            CREATE TABLE IF NOT EXISTS tollkeeper_signal_deps (
                upstream_table   TEXT NOT NULL,
                upstream_ctx     TEXT NOT NULL DEFAULT '{}',
                downstream_table TEXT NOT NULL,
                downstream_ctx   TEXT NOT NULL DEFAULT '{}',
                cascade_policy   TEXT NOT NULL DEFAULT 'notify',
                PRIMARY KEY (upstream_table, upstream_ctx, downstream_table, downstream_ctx)
            );
        """)

    @staticmethod
    def _ctx_key(ctx: dict | None) -> str:
        return json.dumps(ctx or {}, sort_keys=True)

    def write(self, signal: Signal) -> None:
        ctx_key = self._ctx_key(signal.execution_ctx)
        now = datetime.now().isoformat()
        self._conn.execute(
            """INSERT INTO tollkeeper_signals (table_name, execution_ctx, status, execution_ts, updated_at, check_summary, metadata)
               VALUES (?, ?, ?, ?, ?, ?, ?)
               ON CONFLICT(table_name, execution_ctx) DO UPDATE SET
                   status=excluded.status, execution_ts=excluded.execution_ts,
                   updated_at=excluded.updated_at, check_summary=excluded.check_summary,
                   metadata=excluded.metadata""",
            (
                signal.table_name,
                ctx_key,
                signal.status,
                signal.execution_ts.isoformat(),
                now,
                signal.check_summary,
                json.dumps(signal.metadata),
            ),
        )
        self._conn.commit()

    def delete(self, table: str, execution_ctx: dict | None = None) -> None:
        ctx_key = self._ctx_key(execution_ctx)
        self._conn.execute(
            "DELETE FROM tollkeeper_signals WHERE table_name = ? AND execution_ctx = ?",
            (table, ctx_key),
        )
        self._conn.commit()

        for ds_table, ds_ctx, policy in self.get_downstream(table, execution_ctx):
            if policy == "cascade":
                self.delete(ds_table, ds_ctx)
            elif policy == "notify" and self._on_delete_callback:
                self._on_delete_callback(table, ds_table, ds_ctx)

    def check(self, table: str, execution_ctx: dict | None = None) -> Signal | None:
        ctx_key = self._ctx_key(execution_ctx)
        row = self._conn.execute(
            "SELECT * FROM tollkeeper_signals WHERE table_name = ? AND execution_ctx = ?",
            (table, ctx_key),
        ).fetchone()
        if row is None:
            return None
        return Signal(
            table_name=row["table_name"],
            execution_ctx=json.loads(row["execution_ctx"]),
            status=row["status"],
            execution_ts=datetime.fromisoformat(row["execution_ts"]),
            check_summary=row["check_summary"],
            metadata=json.loads(row["metadata"]),
        )

    def write_dq_result(self, result: DqResult) -> None:
        ctx_key = self._ctx_key(result.execution_ctx)
        self._conn.execute(
            """INSERT INTO tollkeeper_dq_results (table_name, execution_ctx, check_name, passed, details, executed_at)
               VALUES (?, ?, ?, ?, ?, ?)
               ON CONFLICT(table_name, execution_ctx, check_name) DO UPDATE SET
                   passed=excluded.passed, details=excluded.details, executed_at=excluded.executed_at""",
            (
                result.table_name,
                ctx_key,
                result.check_name,
                int(result.passed),
                result.details,
                result.executed_at.isoformat(),
            ),
        )
        self._conn.commit()

    def get_dq_results(self, table: str, execution_ctx: dict | None = None) -> list[DqResult]:
        ctx_key = self._ctx_key(execution_ctx)
        rows = self._conn.execute(
            "SELECT table_name, execution_ctx, check_name, passed, details, executed_at FROM tollkeeper_dq_results WHERE table_name = ? AND execution_ctx = ?",
            (table, ctx_key),
        ).fetchall()
        return [
            DqResult(
                table_name=r["table_name"],
                check_name=r["check_name"],
                passed=bool(r["passed"]),
                details=r["details"],
                execution_ctx=json.loads(r["execution_ctx"]),
                executed_at=datetime.fromisoformat(r["executed_at"]),
            )
            for r in rows
        ]

    def delete_dq_results(self, table: str, execution_ctx: dict | None = None) -> None:
        ctx_key = self._ctx_key(execution_ctx)
        self._conn.execute(
            "DELETE FROM tollkeeper_dq_results WHERE table_name = ? AND execution_ctx = ?",
            (table, ctx_key),
        )
        self._conn.commit()

    def register_dep(
        self,
        upstream_table: str,
        downstream_table: str,
        cascade_policy: str = "notify",
        upstream_ctx: dict | None = None,
        downstream_ctx: dict | None = None,
    ) -> None:
        if cascade_policy not in ("notify", "cascade"):
            raise ValueError("cascade_policy must be 'notify' or 'cascade'")
        self._conn.execute(
            """INSERT OR REPLACE INTO tollkeeper_signal_deps
               (upstream_table, upstream_ctx, downstream_table, downstream_ctx, cascade_policy)
               VALUES (?, ?, ?, ?, ?)""",
            (
                upstream_table,
                self._ctx_key(upstream_ctx),
                downstream_table,
                self._ctx_key(downstream_ctx),
                cascade_policy,
            ),
        )
        self._conn.commit()

    def get_downstream(self, table: str, execution_ctx: dict | None = None) -> list[tuple[str, dict, str]]:
        rows = self._conn.execute(
            "SELECT downstream_table, downstream_ctx, cascade_policy FROM tollkeeper_signal_deps WHERE upstream_table = ? AND upstream_ctx = ?",
            (table, self._ctx_key(execution_ctx)),
        ).fetchall()
        return [(r["downstream_table"], json.loads(r["downstream_ctx"]), r["cascade_policy"]) for r in rows]

    def close(self) -> None:
        self._conn.close()

tollkeeper.signals.dbapi.DbApiSignalStore

Bases: SignalStore

Signal store backed by any PEP 249 (DB-API 2.0) connection.

Parameters:

Name Type Description Default
connection Any

A DB-API 2.0 connection (psycopg2, mysql-connector, sqlite3, etc.).

required
paramstyle str

Parameter marker style — "qmark" (?) or "format" (%s). Defaults to "qmark".

'qmark'
Source code in src/tollkeeper/signals/dbapi.py
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
class DbApiSignalStore(SignalStore):
    """Signal store backed by any PEP 249 (DB-API 2.0) connection.

    Args:
        connection: A DB-API 2.0 connection (psycopg2, mysql-connector, sqlite3, etc.).
        paramstyle: Parameter marker style — "qmark" (?) or "format" (%s). Defaults to "qmark".
    """

    def __init__(
        self,
        connection: Any,
        *,
        paramstyle: str = "qmark",
        on_delete_callback: Callable[[str, str, dict], None] | None = None,
    ) -> None:
        super().__init__(on_delete_callback=on_delete_callback)
        self._conn = connection
        if paramstyle not in ("qmark", "format"):
            raise ValueError("paramstyle must be 'qmark' or 'format'")
        self._ph = "?" if paramstyle == "qmark" else "%s"
        self._init_schema()

    def _p(self, sql: str) -> str:
        return sql.replace("?", self._ph)

    def _init_schema(self) -> None:
        cur = self._conn.cursor()
        cur.execute(
            self._p("""
            CREATE TABLE IF NOT EXISTS tollkeeper_signals (
                table_name    VARCHAR(512) NOT NULL,
                execution_ctx VARCHAR(2048) NOT NULL DEFAULT '{}',
                status        VARCHAR(20) NOT NULL,
                execution_ts  VARCHAR(64) NOT NULL,
                updated_at    VARCHAR(64) NOT NULL,
                check_summary VARCHAR(4096) DEFAULT '',
                metadata      VARCHAR(4096) DEFAULT '{}',
                PRIMARY KEY (table_name, execution_ctx)
            )
        """)
        )
        cur.execute(
            self._p("""
            CREATE TABLE IF NOT EXISTS tollkeeper_dq_results (
                table_name    VARCHAR(512) NOT NULL,
                execution_ctx VARCHAR(2048) NOT NULL DEFAULT '{}',
                check_name    VARCHAR(256) NOT NULL,
                passed        INTEGER NOT NULL,
                details       VARCHAR(4096) DEFAULT '',
                executed_at   VARCHAR(64) NOT NULL,
                PRIMARY KEY (table_name, execution_ctx, check_name)
            )
        """)
        )
        cur.execute(
            self._p("""
            CREATE TABLE IF NOT EXISTS tollkeeper_signal_deps (
                upstream_table   VARCHAR(512) NOT NULL,
                upstream_ctx     VARCHAR(2048) NOT NULL DEFAULT '{}',
                downstream_table VARCHAR(512) NOT NULL,
                downstream_ctx   VARCHAR(2048) NOT NULL DEFAULT '{}',
                cascade_policy   VARCHAR(20) NOT NULL DEFAULT 'notify',
                PRIMARY KEY (upstream_table, upstream_ctx, downstream_table, downstream_ctx)
            )
        """)
        )
        self._conn.commit()

    @staticmethod
    def _ctx_key(ctx: dict | None) -> str:
        return json.dumps(ctx or {}, sort_keys=True)

    def write(self, signal: Signal) -> None:
        ctx_key = self._ctx_key(signal.execution_ctx)
        now = datetime.now().isoformat()
        cur = self._conn.cursor()
        cur.execute(
            self._p("DELETE FROM tollkeeper_signals WHERE table_name = ? AND execution_ctx = ?"),
            (signal.table_name, ctx_key),
        )
        cur.execute(
            self._p("""INSERT INTO tollkeeper_signals
                       (table_name, execution_ctx, status, execution_ts, updated_at, check_summary, metadata)
                       VALUES (?, ?, ?, ?, ?, ?, ?)"""),
            (
                signal.table_name,
                ctx_key,
                signal.status,
                signal.execution_ts.isoformat(),
                now,
                signal.check_summary,
                json.dumps(signal.metadata),
            ),
        )
        self._conn.commit()

    def delete(self, table: str, execution_ctx: dict | None = None) -> None:
        ctx_key = self._ctx_key(execution_ctx)
        cur = self._conn.cursor()
        cur.execute(
            self._p("DELETE FROM tollkeeper_signals WHERE table_name = ? AND execution_ctx = ?"), (table, ctx_key)
        )
        self._conn.commit()

        for ds_table, ds_ctx, policy in self.get_downstream(table, execution_ctx):
            if policy == "cascade":
                self.delete(ds_table, ds_ctx)
            elif policy == "notify" and self._on_delete_callback:
                self._on_delete_callback(table, ds_table, ds_ctx)

    def check(self, table: str, execution_ctx: dict | None = None) -> Signal | None:
        ctx_key = self._ctx_key(execution_ctx)
        cur = self._conn.cursor()
        cur.execute(
            self._p("SELECT * FROM tollkeeper_signals WHERE table_name = ? AND execution_ctx = ?"), (table, ctx_key)
        )
        row = cur.fetchone()
        if row is None:
            return None
        return Signal(
            table_name=row[0],
            execution_ctx=json.loads(row[1]),
            status=row[2],
            execution_ts=datetime.fromisoformat(row[3]),
            check_summary=row[5],
            metadata=json.loads(row[6]),
        )

    def write_dq_result(self, result: DqResult) -> None:
        ctx_key = self._ctx_key(result.execution_ctx)
        cur = self._conn.cursor()
        cur.execute(
            self._p("DELETE FROM tollkeeper_dq_results WHERE table_name = ? AND execution_ctx = ? AND check_name = ?"),
            (result.table_name, ctx_key, result.check_name),
        )
        cur.execute(
            self._p("""INSERT INTO tollkeeper_dq_results
                       (table_name, execution_ctx, check_name, passed, details, executed_at)
                       VALUES (?, ?, ?, ?, ?, ?)"""),
            (
                result.table_name,
                ctx_key,
                result.check_name,
                int(result.passed),
                result.details,
                result.executed_at.isoformat(),
            ),
        )
        self._conn.commit()

    def get_dq_results(self, table: str, execution_ctx: dict | None = None) -> list[DqResult]:
        ctx_key = self._ctx_key(execution_ctx)
        cur = self._conn.cursor()
        cur.execute(
            self._p(
                "SELECT table_name, execution_ctx, check_name, passed, details, executed_at FROM tollkeeper_dq_results WHERE table_name = ? AND execution_ctx = ?"
            ),
            (table, ctx_key),
        )
        return [
            DqResult(
                table_name=r[0],
                check_name=r[2],
                passed=bool(r[3]),
                details=r[4],
                execution_ctx=json.loads(r[1]),
                executed_at=datetime.fromisoformat(r[5]),
            )
            for r in cur.fetchall()
        ]

    def delete_dq_results(self, table: str, execution_ctx: dict | None = None) -> None:
        ctx_key = self._ctx_key(execution_ctx)
        cur = self._conn.cursor()
        cur.execute(
            self._p("DELETE FROM tollkeeper_dq_results WHERE table_name = ? AND execution_ctx = ?"), (table, ctx_key)
        )
        self._conn.commit()

    def register_dep(
        self,
        upstream_table: str,
        downstream_table: str,
        cascade_policy: str = "notify",
        upstream_ctx: dict | None = None,
        downstream_ctx: dict | None = None,
    ) -> None:
        if cascade_policy not in ("notify", "cascade"):
            raise ValueError("cascade_policy must be 'notify' or 'cascade'")
        cur = self._conn.cursor()
        cur.execute(
            self._p(
                "DELETE FROM tollkeeper_signal_deps WHERE upstream_table = ? AND upstream_ctx = ? AND downstream_table = ? AND downstream_ctx = ?"
            ),
            (upstream_table, self._ctx_key(upstream_ctx), downstream_table, self._ctx_key(downstream_ctx)),
        )
        cur.execute(
            self._p("""INSERT INTO tollkeeper_signal_deps
                       (upstream_table, upstream_ctx, downstream_table, downstream_ctx, cascade_policy)
                       VALUES (?, ?, ?, ?, ?)"""),
            (
                upstream_table,
                self._ctx_key(upstream_ctx),
                downstream_table,
                self._ctx_key(downstream_ctx),
                cascade_policy,
            ),
        )
        self._conn.commit()

    def get_downstream(self, table: str, execution_ctx: dict | None = None) -> list[tuple[str, dict, str]]:
        cur = self._conn.cursor()
        cur.execute(
            self._p(
                "SELECT downstream_table, downstream_ctx, cascade_policy FROM tollkeeper_signal_deps WHERE upstream_table = ? AND upstream_ctx = ?"
            ),
            (table, self._ctx_key(execution_ctx)),
        )
        return [(r[0], json.loads(r[1]), r[2]) for r in cur.fetchall()]

    def close(self) -> None:
        self._conn.close()