diff --git a/CONTRACT.md b/CONTRACT.md new file mode 100644 index 0000000..9c6ecaa --- /dev/null +++ b/CONTRACT.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 13b47e0..b1f708e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/contract/__init__.py b/tests/contract/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/contract/test_behavior.py b/tests/contract/test_behavior.py new file mode 100644 index 0000000..5bec28f --- /dev/null +++ b/tests/contract/test_behavior.py @@ -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(" '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." + )