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
14 changes: 14 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.env
.coverage
.gitignore
.idea
.ruff_cache
.vscode
.git
.pytest_cache
.DS_Store
*.yml
Dockerfile
**/__pycache__
.hypothesis
.venv
124 changes: 124 additions & 0 deletions .github/workflows/_checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
name: checks

on:
workflow_call: {}

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: extractions/setup-just@v4
- uses: astral-sh/setup-uv@v8.2.0
with:
enable-cache: true
cache-dependency-glob: "**/pyproject.toml"
- run: uv python install 3.11
- run: uv python pin 3.11
- run: just install lint-ci

links:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Check local links
uses: lycheeverse/lychee-action@v2
with:
args: >-
--offline
--no-progress
--remap
'https://github\.com/${{ github.repository }}/(?:blob|tree)/main/(.*) file://${{ github.workspace }}/$1'
'**/*.md'

pytest:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version:
- "3.11"
- "3.12"
- "3.13"
- "3.14"
- "3.14t"
- "3.15"
- "3.15t"
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: inbox
POSTGRES_PASSWORD: inbox
POSTGRES_DB: inbox
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U inbox"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v6
- uses: extractions/setup-just@v4
- uses: astral-sh/setup-uv@v8.2.0
with:
enable-cache: true
cache-dependency-glob: "**/pyproject.toml"
- run: uv python install ${{ matrix.python-version }}
- run: uv python pin ${{ matrix.python-version }}
- run: just install
- name: Assert the GIL is actually disabled
if: endsWith(matrix.python-version, 't')
run: uv run --no-sync python -c "import sys, asyncpg, sqlalchemy, sqlalchemy_inbox; assert not sys._is_gil_enabled(), 'GIL re-enabled - a dependency lacks free-threaded support'"
- run: just test-ci
env:
PYTHONDONTWRITEBYTECODE: 1
PYTHONUNBUFFERED: 1
POSTGRES_DSN: postgresql+asyncpg://inbox:inbox@127.0.0.1:5432/inbox

floors:
# Installs the declared floors on each Python, against the oldest supported PostgreSQL.
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version:
- "3.11"
- "3.12"
- "3.13"
- "3.14"
services:
postgres:
image: postgres:14
env:
POSTGRES_USER: inbox
POSTGRES_PASSWORD: inbox
POSTGRES_DB: inbox
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U inbox"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v8.2.0
with:
enable-cache: true
cache-dependency-glob: "**/pyproject.toml"
- run: uv python install ${{ matrix.python-version }}
- run: uv python pin ${{ matrix.python-version }}
- run: uv venv
- if: matrix.python-version == '3.11'
run: uv pip compile pyproject.toml --no-deps --resolution lowest-direct -o floors.txt
- if: matrix.python-version != '3.11'
run: uv pip compile pyproject.toml --resolution lowest-direct --no-build -o floors.txt
- run: uv pip install -r pyproject.toml --group dev --constraints floors.txt --no-build
- run: uv run --no-sync pytest . --no-cov
env:
PYTHONDONTWRITEBYTECODE: 1
PYTHONUNBUFFERED: 1
POSTGRES_DSN: postgresql+asyncpg://inbox:inbox@127.0.0.1:5432/inbox
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
name: main

on:
push:
branches:
- main
pull_request: {}

concurrency:
group: ${{ github.head_ref || github.run_id }}
cancel-in-progress: true

jobs:
checks:
uses: ./.github/workflows/_checks.yml
55 changes: 55 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Release

# Tag-driven: pushing a semver tag publishes to PyPI and creates the matching
# GitHub Release. Replaces the old `on: release: published` publish.yml — that
# trigger is removed so the published Release this workflow creates can't re-fire
# it (double-publish). The tag is the sole entry point; by convention a tag is
# only cut off a green main, so there is no in-workflow CI gate.
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+' # stable: 2.7.2
- '[0-9]+.[0-9]+.[0-9]+[a-z]+[0-9]+' # pre-release: 2.0.0rc1, 4.0.0a2

# contents: write -> create the GitHub Release; id-token: write -> OIDC for PyPI Trusted Publishing.
permissions:
contents: write
id-token: write

jobs:
release:
runs-on: ubuntu-latest
environment: pypi # scopes the PyPI Trusted Publisher; hook for approval rules
steps:
- uses: actions/checkout@v6
- uses: extractions/setup-just@v4
- uses: astral-sh/setup-uv@v7

# PyPI is irreversible, so it runs FIRST: if it fails the job stops and no
# GitHub Release is created advertising a version that never reached PyPI.
# `just publish` derives the version from $GITHUB_REF_NAME (the tag name).
# Auth via PyPI Trusted Publishing (OIDC); no PYPI_TOKEN. Needs a Trusted
# Publisher on the sqlalchemy-inbox PyPI project (env: pypi, workflow: release.yml).
- run: just publish

# The Release body is GitHub's generated notes, rendered from the squashed
# PR titles since the previous tag — so a conventional-commit title is what
# a reader gets. A release wanting prose is edited after the fact with
# `gh release edit <tag> --notes-file`. A tag with a letter (2.0.0rc1) is a
# pre-release -> flagged so GitHub won't mark it "Latest".
- name: Resolve release metadata
id: meta
run: |
set -euo pipefail
if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi

- name: Publish GitHub Release
uses: softprops/action-gh-release@v3
with:
generate_release_notes: true
prerelease: ${{ steps.meta.outputs.prerelease }}
draft: false
20 changes: 20 additions & 0 deletions .github/workflows/scheduled.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: scheduled-dep-check
on:
schedule:
- cron: "0 6 * * *" # Daily 06:00 UTC
workflow_dispatch: {}

concurrency:
group: scheduled-dep-check
cancel-in-progress: false

jobs:
checks:
uses: ./.github/workflows/_checks.yml

report-failure:
needs: checks
if: failure() && github.event_name == 'schedule'
permissions:
issues: write
uses: modern-python/.github/.github/workflows/report-scheduled-failure.yml@main
22 changes: 22 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generic things
*.pyc
*~
__pycache__/*
*.swp
*.sqlite3
*.map
.vscode
.idea
.DS_Store
.env
.pytest_cache
.ruff_cache
.coverage
htmlcov/
coverage.xml
pytest.xml
dist/
.python-version
.venv
uv.lock
/site/
39 changes: 39 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# AGENTS.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

`sqlalchemy-inbox` is a transactional inbox ledger for SQLAlchemy asyncio on PostgreSQL.
[`CONTEXT.md`](CONTEXT.md) opens with what it does and owns the vocabulary. Read it before naming a
concept in code, a test name, or an issue title.

## Commands

`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth.
The things it does not say:

- `just test [args]` forwards args **unquoted**, so a spaced `-k` expression word-splits and fails.
- Every test needs PostgreSQL and skips if `POSTGRES_DSN` is unreachable. The compose Postgres
publishes no host port, so run tests through `just test`. The coverage gate is on in CI; pass
`--no-cov` while iterating locally if you add `--cov`.

## Architecture

- `Inbox` never commits, rolls back, begins, or opens a session or connection. Transaction
ownership stays with the caller. Any new method must keep that.
- `Inbox.claim` requires an open transaction on its session. That is what turns a wrongly wired
second session into an error instead of a marker that never commits.
- Concurrency claims are proven only by tests on separate connections, never one shared test
transaction.

## Workflow

Every link in `README.md` must be absolute: `https://github.com/modern-python/sqlalchemy-inbox/blob/main/<path>`.
`README.md` is also the PyPI long description, and PyPI does not rewrite relative links.

## Code Style

- **Never use local/inline imports**, tests included. `ruff` catches them.
- Annotate every function argument.
- Docstrings: public API documents the contract; internal helpers get a one-line contract at most.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
38 changes: 38 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# sqlalchemy-inbox

A processed-message ledger for consumers on SQLAlchemy's asyncio extension and PostgreSQL: one row
per (scope, message id), inserted in the caller's transaction so it commits or rolls back with the
business writes. Postgres-only. It stores no payload and runs no worker.

## Language

A term is listed only when there is a synonym to reject, or a meaning subtle enough that code and
docs must agree on it. General programming vocabulary does not belong here, however heavily this
project uses it.

**Marker**:
One ledger row, keyed by `(scope, message_id)`. Its presence after commit means the database
effects for that key have committed.
_Avoid_: lock, processed event, dedup record

**Scope**:
A stable string naming one logical consumer of a message, such as `chats.create-chat.v1`. Two
consumers that process the same message independently use different scopes.
_Avoid_: handler name, consumer group (both change on refactors and deployment renames)

**Message id**:
The producer-assigned identity of a message, the same on every delivery.
_Avoid_: offset, delivery id (both change between deliveries)

**Claim**:
Inserting a marker in the caller's open transaction. A claim that inserted returns `True`; one
that found a committed marker returns `False`.
_Avoid_: acquire, lock, register

**Retention**:
How long a marker is kept. After it is deleted, the same key is new again.
_Avoid_: TTL (nothing expires on its own; cleanup deletes)

**Cutoff**:
The database timestamp, taken once per cleanup run, before which markers are deleted.
_Avoid_: deadline, expiry time
24 changes: 24 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
FROM python:3.14-slim

COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
RUN useradd --no-create-home --gid root runner

ENV UV_PROJECT_ENVIRONMENT=/code/.venv \
UV_NO_MANAGED_PYTHON=1 \
UV_NO_CACHE=true \
UV_LINK_MODE=copy

WORKDIR /code

COPY pyproject.toml .
COPY uv.lock .

RUN uv sync --all-extras --frozen --no-install-project

COPY . .

RUN chown -R runner:root /code && chmod -R g=u /code

USER runner

RUN uv sync --all-extras --frozen
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Artur Shiriev

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Loading
Loading