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
50 changes: 50 additions & 0 deletions CONTRACT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Public API Contract

This document defines the stability promise `pl-row-encode` makes to its users and the
rules that keep that promise mechanically enforceable.

## The promise (SemVer)

We follow [Semantic Versioning](https://semver.org/). For a given **major** version:

- The **public surface** is frozen: the names exported from `pl_row_encode.__all__`, and
their call signatures, do not change in a breaking way.
- The **documented behavior** of those functions does not change in a breaking way.
- The **token wire format** is stable: a token produced by any release within a major
version decodes correctly with any later release in the same major version.

Additions (new functions, new *optional* keyword arguments, new accepted input types) are
allowed in **minor** releases. Bug fixes go in **patch** releases.

Anything that breaks one of the three guarantees above requires a **major** version bump
and an entry in the changelog.

## How it is enforced

The contract is not a gentleman's agreement — it is executable. Two layers live under
`tests/contract/`:

| File | Guards |
|------|--------|
| `test_surface.py` | The exported symbols and their exact signatures. |
| `test_behavior.py` | Documented round-trip behavior, dtypes, errors, and the token wire format (via a frozen golden token). |

### The append-only rule

Within a major version, the tests in `tests/contract/` are **append-only**:

- You **may** add new contract tests (covering new, additive API).
- You **may not** weaken, change, or delete an existing contract test.

If a change requires editing an existing contract assertion, that change is breaking by
definition. Do not edit the assertion to make CI green — instead, bump the major version
and record the break here and in the changelog.

A red contract suite means "this is a breaking change." That is the whole point.

## Updating the surface snapshot

`test_surface.py` pins exact signatures. When you make an **additive** change (e.g. a new
function, or a new optional keyword argument), update the snapshot in the same PR and
release it as a minor version. When you find yourself wanting to update it to *remove* or
*retype* something, that is a major change.
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ build-backend = "maturin"
python-source = "."
module-name = "pl_row_encode._internal"

[tool.pytest.ini_options]
# Run the >>> examples in the public docstrings as tests, so the documented
# usage can't silently rot. This is the third contract layer alongside
# tests/contract/ (see CONTRACT.md).
addopts = "--doctest-modules"

[dependency-groups]
dev = [
"ruff>=0.15.15",
Expand Down
Empty file added tests/contract/__init__.py
Empty file.
121 changes: 121 additions & 0 deletions tests/contract/test_behavior.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Freeze the *behavior* users depend on: round-trips, dtypes, errors, wire format.

APPEND-ONLY within a major version. You may add tests for new, additive behavior; you may
not weaken or delete an assertion here. A red test in this file means a breaking change.
See CONTRACT.md.
"""

import polars as pl
import pytest
from polars.testing import assert_series_equal

from pl_row_encode import (
decode,
decode_peek,
decode_series,
encode,
encode_series,
get_header,
)

# --- round-trip behavior --------------------------------------------------


def test_encode_produces_binary_token():
df = pl.DataFrame({"x": [1, 2], "y": ["a", "b"]})
out = df.select(encode("x", "y").alias("tok"))
assert out.schema == pl.Schema({"tok": pl.Binary})


def test_lazy_roundtrip_via_decode_with_header():
df = pl.DataFrame({"x": [1, 2], "y": ["a", "b"]}).select(
encode("x", "y").alias("tok")
)
header = get_header(df.to_series())
rows = (
df.select(decode("tok", schema_header=header).alias("row"))
.to_series()
.to_list()
)
assert rows == [{"x": 1, "y": "a"}, {"x": 2, "y": "b"}]


def test_lazy_roundtrip_via_decode_peek():
df = pl.DataFrame({"x": [1, 2], "y": ["a", "b"]}).select(
encode("x", "y").alias("tok")
)
rows = df.select(decode_peek(df, "tok").alias("row")).to_series().to_list()
assert rows == [{"x": 1, "y": "a"}, {"x": 2, "y": "b"}]


def test_eager_roundtrip_via_series():
tok = encode_series(pl.Series("x", [10, 20]))
assert tok.dtype == pl.Binary
assert decode_series(tok).to_list() == [{"x": 10}, {"x": 20}]


def test_encode_series_matches_encode_expr():
df = pl.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]})
via_expr = df.select(tok=encode("a", "b"))["tok"]
via_series = encode_series(df["a"], df["b"])
assert_series_equal(via_series.rename("tok"), via_expr)


# --- documented errors ----------------------------------------------------


def test_encode_requires_input():
with pytest.raises(ValueError, match="at least one column"):
encode()


def test_encode_series_requires_input():
with pytest.raises(ValueError, match="at least one series"):
encode_series()


def test_get_header_rejects_all_null_series():
with pytest.raises(ValueError, match="all-null"):
get_header(pl.Series("x", [None], dtype=pl.Binary))


def test_get_header_rejects_short_token():
with pytest.raises(ValueError, match="too short"):
get_header(b"\x00")


def test_decode_peek_rejects_no_non_null_token():
df = pl.DataFrame({"tok": pl.Series([None], dtype=pl.Binary)})
with pytest.raises(ValueError, match="no non-null token"):
decode_peek(df, "tok")


# --- header shape ---------------------------------------------------------


def test_header_is_length_prefixed():
import struct

tok = encode_series(pl.Series("x", [1, 2, 3]))
header = get_header(tok)
(header_len,) = struct.unpack_from("<I", header, 0)
assert header_len == len(header) - 4
# header sniffed from a single token equals header sniffed from the series
assert get_header(tok[0]) == header


# --- token wire format (cross-version stability) --------------------------

# A token produced by an early release. Any later release in the same major version MUST
# still decode it to the same rows. If this test fails, the on-disk/over-the-wire token
# format changed -- that is a breaking change for anyone who persisted tokens. Bump major.
GOLDEN_TOKEN = bytes.fromhex(
"220000000200000000000000010000000000000078090000000100000000000000790e000000018000"
"0000000000010161"
)
GOLDEN_ROWS = [{"x": 1, "y": "a"}]


def test_golden_token_still_decodes():
decoded = decode_series(pl.Series("tok", [GOLDEN_TOKEN], dtype=pl.Binary)).to_list()
assert decoded == GOLDEN_ROWS
59 changes: 59 additions & 0 deletions tests/contract/test_surface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Freeze the *shape* of the public API: exported names and exact signatures.

APPEND-ONLY within a major version. A failure here means the public surface drifted.
If the drift is additive (new symbol, new optional kwarg), update the snapshot in this
file and ship a minor release. If it removes or retypes something, it is a breaking
change: bump the major version and record it in CONTRACT.md.

See CONTRACT.md for the full policy.
"""

import inspect

import pl_row_encode as m

# The frozen set of public symbols. Add to this set for additive releases; never remove.
EXPORTED = {
"encode",
"encode_series",
"get_header",
"decode",
"decode_peek",
"decode_series",
}

# Exact string form of each public signature. Adding an *optional* keyword argument is
# additive (update the snapshot, minor release); changing or removing a parameter is
# breaking (major release).
SIGNATURES = {
"encode": "(*exprs: 'IntoExpr') -> 'pl.Expr'",
"encode_series": "(*series: 'pl.Series') -> 'pl.Series'",
"get_header": "(token: 'bytes | pl.Series') -> 'bytes'",
"decode": "(expr: 'IntoExpr', *, schema_header: 'bytes') -> 'pl.Expr'",
"decode_peek": "(frame: 'Frame', column: 'str') -> 'pl.Expr'",
"decode_series": "(s: 'pl.Series') -> 'pl.Series'",
}


def test_all_matches_frozen_set():
assert set(m.__all__) == EXPORTED


def test_exported_symbols_are_importable():
for name in EXPORTED:
assert hasattr(m, name), f"{name} is exported in __all__ but not importable"


def test_no_undeclared_signatures():
# Every exported symbol must have a pinned signature below, so new public API can't
# slip in without consciously freezing its signature.
assert set(SIGNATURES) == EXPORTED


def test_signatures_frozen():
for name, expected in SIGNATURES.items():
actual = str(inspect.signature(getattr(m, name)))
assert actual == expected, (
f"signature of {name} changed: {actual!r} != frozen {expected!r}. "
"If additive, update SIGNATURES (minor release); if not, bump major."
)
Loading