Skip to content

feat(python): decrypt rows back into models - #1509

Draft
yujiyokoo wants to merge 3 commits into
feat/python-sdk-encryptfrom
feat/python-sdk-decrypt
Draft

yujiyokoo wants to merge 3 commits into
feat/python-sdk-encryptfrom
feat/python-sdk-decrypt

Conversation

@yujiyokoo

Copy link
Copy Markdown
Contributor

Description:

Summary

Cipher.decrypt now turns rows read from PostgreSQL back into instances of the declared model. EQL (Encrypt Query Language) is CipherStash's set of PostgreSQL types for encrypted values. Each row is a mapping of column names to values, as psycopg's dict_row returns it. All the rows are opened with one key request per 500 sealed values, and the model's own constructor builds each instance.

Changes

  • Python API: Cipher.decrypt(rows) returns a list of instances of the model class. The new DecryptError is a StashError. The README has a new section, "Read the rows back", with a dict_row example.
  • Checks before any key request: a row that is not a mapping is decrypt_row_shape. A missing declared column is decrypt_missing_column. A column value that is not EQL JSON is decrypt_malformed. A value stored under another table or column is decrypt_foreign_row. A passthrough value of the wrong type is an InvalidValueError. Each error names the row. Each error names the field too, except decrypt_row_shape.
  • Checks after the key requests: a value sealed as another type is decrypt_kind_mismatch. An opened date or timestamp that Python cannot hold is decrypt_not_representable. No instance is built when any value fails.
  • Column value forms: an EQL column can be a str, UTF-8 bytes, or a dict that the driver already parsed. Columns the model does not declare are ignored.
  • Keysets: a row sealed under any keyset of the client decrypts. The Go SDK's Client.Open does the same.
  • Errors: stack_encrypt_foreign_keyset and stack_encrypt_context_mismatch are now AuthenticationError.
  • Tests: offline round trips in the core crate, with a fake key source. The live tests now read rows back from postgres:17 for dataclasses, attrs, Pydantic, NamedTuple, and SQLAlchemy models. The credential fixtures are in a new tests/conftest.py.

Verification

  • pytest on Python 3.14 and 3.9: 267 passed, 16 skipped. The skips are the 14 live tests and 2 gate-probe tests.
  • mise run test:cargo: 24 core tests and 5 engine tests passed.
  • clippy, cargo fmt --check, and mypy: no findings.
  • pnpm run test:scripts: 83 of 83 files pass.
  • The live tests have not run on this machine. This machine has no CipherStash credentials. The first run is the live job on this PR.

Related

Closes #1390. Refs #1341 and #1508.

Review notes

🤖 Generated with Claude Code

yujiyokoo and others added 3 commits November 2, 2026 21:04
Cipher.decrypt takes the rows psycopg read with dict_row and returns
instances of the declared class, built with its constructor. A sealed
column may be EQL JSON as a str, as UTF-8 bytes, or as a dict. Other
columns are ignored.

Every row is checked before any key request: its shape, its declared
columns, its EQL JSON, and the table and column the JSON's "i" names.
Each passthrough value is checked by exact type, as on encrypt. A
refused row raises the new DecryptError or InvalidValueError, and the
call returns no instance.

The core crate decrypts with the engine's fake key source in its tests,
so the round trip is tested without ZeroKMS. The engine does not say
which field a kind mismatch was in. So decrypt_kind_mismatch names the
field only when the plan has one sealed field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Open rows under the client scope, as Go's Client.Open does. A row
sealed under another keyset of the same client now decrypts.

Map stack_encrypt_foreign_keyset and stack_encrypt_context_mismatch
to AuthenticationError.

Name the row in a malformed-value error, and check for a duplicate
typed null as the scalar path does.

Borrow str and bytes column values, so the EQL text is copied once,
into the engine's frame. Share the frame helpers between encrypt and
decrypt, and move the credential fixtures into conftest.py.

A kind mismatch names its field only when the model has one sealed
field. The engine does not name the field yet. #1508 tracks that.

The README says that a passthrough datetime needs a timestamptz
column, because psycopg reads timestamp as a naive datetime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Encode a str column value with encode_utf8, and use the bytes it
returns. Under abi3-py39, PyString::to_cow encodes the text and then
copies it again, on every Python version. Borrowing the UTF-8 of a str
is in the limited API only from Python 3.10.

Add a test that the engine names the field of a value with only an
identifier, in a model with two sealed fields. So the field of
decrypt_malformed does not depend on #1508.

Move the untouched fixture into conftest.py, so the decrypt and
encrypt tests share one copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Nov 2, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 37f4206

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

@blacksmith-sh

blacksmith-sh Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Found 2 test failures on Blacksmith runners:

Failures

Test View Logs
test_encrypt_live/test_a_sqlalchemy_class_is_built_without_its_ignored_relationship View Logs
test_encrypt_live/
test_rows_read_back_with_dict_row_decrypt_into_the_models[SqlAlchemyUser]
View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant