Skip to content
Draft
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
13 changes: 13 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
version: 2
updates:
- package-ecosystem: pip
directory: /
schedule:
interval: weekly
groups:
python-development:
dependency-type: development
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
name: CI
on: [push, pull_request]

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
checks:
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
matrix:
python-version: ['3.13', '3.14']
Expand All @@ -16,6 +24,7 @@ jobs:

build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
Expand All @@ -30,10 +39,34 @@ jobs:
dist/*.tar.gz
if-no-files-found: error

framework-compatibility:
name: Frameworks ${{ matrix.frameworks }} / Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.13', '3.14']
frameworks: [minimum, current]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: {python-version: '${{ matrix.python-version }}'}
- name: Install adapter test dependencies
shell: bash
run: |
python -m pip install -e . pytest pytest-asyncio
if [ '${{ matrix.frameworks }}' = minimum ]; then
python -m pip install 'fastapi>=0.110,<0.111' 'starlette>=0.37,<0.38'
else
python -m pip install 'fastapi>=0.110' 'starlette>=0.37'
fi
- run: python -m pytest tests/test_adapters.py

installation:
name: Python ${{ matrix.python-version }} / ${{ matrix.installation }}
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
services:
redis:
image: redis:7-alpine
Expand Down
54 changes: 54 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Release

on:
push:
tags: ['v*']

permissions:
contents: read

jobs:
build:
if: github.repository == 'Forebase/Eventful'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write
attestations: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- run: python -m pip install build
- name: Require tag and package versions to match
shell: bash
run: |
package_version=$(python -c 'exec(open("src/eventful/__version__.py").read()); print(__version__)')
test "v${package_version}" = "${GITHUB_REF_NAME}"
- run: python -m build
- uses: actions/attest-build-provenance@v2
with:
subject-path: 'dist/*'
- uses: actions/upload-artifact@v4
with:
name: release-distributions
path: dist/*
if-no-files-found: error

publish:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: pypi
url: https://pypi.org/project/eventful/
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: release-distributions
path: dist
- uses: pypa/gh-action-pypi-publish@release/v1
41 changes: 41 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Security

on:
push:
branches: [main, dev/alpha]
pull_request:
schedule:
- cron: '17 4 * * 1'

permissions:
contents: read

concurrency:
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
dependency-audit:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
cache: pip
- run: python -m pip install '.[all]' pip-audit
- run: python -m pip_audit

codeql:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with:
languages: python
- uses: github/codeql-action/analyze@v3
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
# Changelog

## 0.2.0 - unreleased
## 0.3.0 - unreleased

- Added deterministic ASGI ownership and shutdown behavior, including failed startup.
- Added release gates for supported Python versions, built distributions, optional
integrations, dependency vulnerabilities, and static security analysis.
- Added a tag-only trusted-publishing workflow with artifact attestations.
- Bounded optional dependency compatibility ranges and documented production
guarantees, limitations, support policy, and operator responsibilities.

## 0.2.0 - 2026-08-23

- Repaired package imports and packaging metadata.
- Established provisional v1 topology and contracts.
Expand Down
6 changes: 5 additions & 1 deletion DEPRECATION.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Deprecation policy

Provisional APIs should warn for at least one minor release when practical. Experimental APIs may change faster but must be recorded in the changelog.
Before 1.0, public API removals warn for at least one minor release when practical
and are recorded in the changelog. A symbol documented as experimental may change
faster, but the change must still be documented. Patch releases do not knowingly
remove public APIs. Security fixes may override the notice period when retaining an
API would leave users exposed.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Eventful

Eventful 0.2.0 is a pre-alpha foundation for a Python event toolkit. The implemented public facade is a local in-memory dispatcher (`Event`, `EventBus`, `InMemoryBus`, `listener`, `emit_sync`, `emit_async`, and the compatibility helper `emit`). v1-oriented packages provide provisional contracts for brokers, durable streams, codecs, stores, middleware, plugins, configuration, schemas, and observability.
Eventful 0.3.0 is a typed event toolkit for Python 3.13 and 3.14. Its supported core is the local dispatcher (`Event`, `EventBus`, `InMemoryBus`, `listener`, `emit_sync`, `emit_async`, and the compatibility helper `emit`), with tested Redis, PostgreSQL, file-persistence, FastAPI, and Starlette integrations.

## Install

Expand Down Expand Up @@ -35,7 +35,7 @@ assert bus.emit_sync(Event(type="user.created", payload="Ada")) == ["hello Ada"]

## API status

No API is stable before 1.0. The root facade is preserved for 0.1 compatibility and treated as provisional. Experimental packages are importable for architecture work but should not be treated as production integrations.
No API is frozen before 1.0. The root facade remains backward compatible within the documented deprecation policy. Production use must be limited to the guarantees and deployment models in [`docs/production-readiness.md`](docs/production-readiness.md).

See `docs/index.md` for the documentation map and `docs/work-register.md` for deferred work.

Expand Down
12 changes: 11 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
# Security policy

Report vulnerabilities privately to the maintainers. Do not file public issues for exploitable behavior until triage is complete.
## Supported versions

Security fixes are made on the latest released minor version. Users should upgrade
to the newest patch before reporting a suspected vulnerability.

## Reporting

Use GitHub's **Security → Report a vulnerability** flow for this repository. Do
not file a public issue for exploitable behavior. Include affected versions, a
minimal reproduction, impact, and any known mitigation. Maintainers will confirm
receipt within five working days and coordinate disclosure after a fix is ready.
35 changes: 30 additions & 5 deletions docs/frameworks.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,17 @@ fallback is intentional.
- An injected `bus=` remains caller-owned by default.
- A bus created by `bus_factory=` or by the middleware default is adapter-owned.
- `close_on_shutdown=True` opts an injected bus into adapter ownership.
- On `lifespan.shutdown.complete`, the adapter awaits `bus.close()` when the owned
bus exposes a synchronous or asynchronous close method.
- Cleanup completes before shutdown success is forwarded to the ASGI server.
- Startup failures do not claim that shutdown cleanup occurred; applications should
manage resources created outside the adapter in their own lifespan handler.
- On `lifespan.shutdown.complete` or `lifespan.shutdown.failed`, the adapter awaits
`bus.close()` when the owned bus exposes a synchronous or asynchronous close
method.
- Cleanup completes before the terminal shutdown result is forwarded to the ASGI
server, including when terminal lifespan calls overlap.
- A failed startup (either `lifespan.startup.failed` or an exception escaping the
lifespan application) also closes an adapter-owned bus. Caller-owned buses remain
untouched in every failure path.
- Cleanup is idempotent: repeated startup/shutdown cycles against the same middleware
instance do not close a bus more than once. Create a new application/middleware
instance to obtain a fresh adapter-owned bus after shutdown.

The local `EventBus` has no resources to close. The generic close behavior exists
for application bus subclasses that coordinate transports, stores, or plugins.
Expand All @@ -81,3 +87,22 @@ The bus itself is application-scoped. Request state isolates access paths, not b
registrations. Use separate application instances or a custom `bus_factory` when
tests or tenants require distinct registration state. `state_key=` supports
coexistence with another request-state convention.

Each middleware instance using the default bus or `bus_factory=` creates its own bus,
so those concurrently running application instances are isolated. Injected buses
have caller-defined scope: injecting the same `bus=` into multiple applications
intentionally shares registrations and delivery state, and isolation is the caller's
responsibility. This is process-local isolation only: pre-fork and multi-worker
deployments create one adapter-owned bus per worker. Eventful does not coordinate
registrations or delivery between workers; inject a caller-managed transport-backed
bus when cross-process behavior is required. Do not share one adapter-owned
middleware instance between event loops.

## Supported versions

The declared dependency floors are FastAPI 0.110 and Starlette 0.37 on Eventful's
supported Python versions. CI exercises those minor-version ranges through the
public ASGI and dependency APIs, and a separate matrix leg resolves the latest
available releases. Versions between the floor and latest tested releases are
expected to work; compatibility with a future major release is not promised until
that release is separately validated.
48 changes: 48 additions & 0 deletions docs/production-readiness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Production readiness

Eventful 0.3 is production-capable within the boundaries below. The project is
still pre-1.0: documented deprecations precede compatibility breaks, but the API
is not yet permanently frozen.

## Supported deployment envelope

| Component | Supported use | Delivery/durability boundary |
| --- | --- | --- |
| `EventBus` / `InMemoryBus` | One Python process; concurrent threads; sync or async listeners | In-memory, no crash recovery |
| `RedisTransport` | Cross-process live fan-out with Redis 5–6 clients | At-most-once Pub/Sub; no replay |
| `PostgresPersistence` | Durable append/replay with PostgreSQL 16 | Transactional records; caller owns retry and retention policy |
| `FilePersistence` | One process with a local filesystem | Flushes each record; no multi-process coordination or fsync guarantee |
| FastAPI / Starlette adapters | Supported framework extras and ASGI lifespan | Adapter-created buses are isolated and closed exactly once |

Python 3.13 and 3.14 are tested from both wheels and source distributions. Core
installation does not import or install optional integration dependencies.

## Release gates

Every change must pass linting, strict contract typing, the complete unit suite,
service-backed Redis and PostgreSQL tests, documentation validation, wheel/sdist
builds, and clean-install smoke tests on both supported Python versions. Security
automation performs dependency auditing and CodeQL analysis. Version tags are
published only through the protected `pypi` GitHub environment and produce signed
artifact attestations.

## Operator responsibilities

- Pin Eventful and integration dependencies in application lock files.
- Set explicit connection URLs; secure Redis/PostgreSQL with network policy,
authentication, TLS, backup, retention, and monitoring appropriate to the system.
- Install an application error handler and observability provider. Alert on
listener, serialization, transport, and persistence failures.
- Use PostgreSQL (or another durable `EventStore`) when loss or replay matters.
- Exercise shutdown, dependency outage, retry, and restore procedures before launch.
- Treat event payloads as application data: Eventful does not encrypt, redact,
authorize, or classify them.

## Explicit non-guarantees

Eventful does not provide exactly-once delivery, distributed transactions,
multi-process file locking, schema evolution, dead-letter queues, broker access
control, or automatic retry policy. Applications requiring those properties must
compose them at their own boundary or use a transport/store that supplies them.

Security reports follow `SECURITY.md`; API removals follow `DEPRECATION.md`.
2 changes: 1 addition & 1 deletion docs/work-register.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ All intentional incompleteness must use an annotation listed in `docs/annotation

| Module | Status | Deferred work |
| --- | --- | --- |
| `eventful.adapters` | provisional ASGI lifecycle | Validate multi-worker ownership and framework-version compatibility (Issue: Forebase/Eventful#1). |
| `eventful.adapters` | validated in-process ASGI lifecycle | Multi-worker delivery remains deployment-managed; major framework versions require separate validation (Issue: Forebase/Eventful#1). |
| `eventful.transports.redis` | provisional, service-validated Pub/Sub | Connection loss/restart gaps, cancellation cleanup, malformed input, concurrent publishing, and shutdown limits validated; delivery remains live at-most-once with no Eventful retry/replay or durability. Redis Streams remains deferred (Forebase/Eventful#2). |
| `eventful.persistence.postgres_persistence` | provisional durable store | Validate migration upgrades, retention, replication, and operational load behavior (Issue: Forebase/Eventful#3). |
| `eventful.contracts` | provisional, reference-validated | Validate async lifecycle, delivery, and durability semantics against real Redis/PostgreSQL integrations (Issue: Forebase/Eventful#4). |
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ nav:
- Framework Adapters: frameworks.md
- Extension Points: extensions.md
- Quickstart: quickstart.md
- Production Readiness: production-readiness.md
- Runnable Examples: examples.md
- Work Register: work-register.md
- Annotation Policy: annotation-policy.md
Expand Down
18 changes: 12 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "eventful"
dynamic = ["version"]
description = "Provisional event dispatch contracts and local in-memory event bus for Python applications"
description = "Typed event dispatch, transport, and persistence building blocks for Python applications"
readme = "README.md"
requires-python = ">=3.13,<3.15"
license = "MIT"
authors = [{name = "Eventful maintainers", email = "team@eventful.org"}]
keywords = ["events", "pubsub", "event-bus", "dispatch"]
classifiers = [
"Development Status :: 2 - Pre-Alpha",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.13",
Expand All @@ -21,12 +21,18 @@ classifiers = [
]
dependencies = []

[project.urls]
Homepage = "https://github.com/Forebase/Eventful"
Documentation = "https://github.com/Forebase/Eventful/tree/main/docs"
Issues = "https://github.com/Forebase/Eventful/issues"
Changelog = "https://github.com/Forebase/Eventful/blob/main/CHANGELOG.md"

[project.optional-dependencies]
redis = ["redis>=5"]
postgres = ["asyncpg>=0.29"]
redis = ["redis>=5,<7"]
postgres = ["asyncpg>=0.29,<1"]
file = [] # Dependency-free UTF-8 JSON Lines persistence backend.
fastapi = ["fastapi>=0.110"]
starlette = ["starlette>=0.37"]
fastapi = ["fastapi>=0.110,<1"]
starlette = ["starlette>=0.37,<1"]
test = ["asyncpg>=0.29", "build>=1.2", "fastapi>=0.110", "pytest>=8.2", "pytest-asyncio>=0.23", "pytest-cov>=5", "redis>=5", "starlette>=0.37"]
docs = ["mkdocs>=1.6"]
dev = ["asyncpg>=0.29", "build>=1.2", "fastapi>=0.110", "mkdocs>=1.6", "mypy>=1.10", "pre-commit>=3.7", "pytest>=8.2", "pytest-asyncio>=0.23", "pytest-cov>=5", "redis>=5", "ruff>=0.5", "starlette>=0.37"]
Expand Down
7 changes: 6 additions & 1 deletion scripts/check_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
annotation = re.compile(rf"\b({ALLOWED})\b:(.*)")
forbidden = re.compile(r"\b(TBD|XXX)\b")
failed: list[str] = []
for path in [p for p in Path('.').rglob('*') if p.is_file() and '.git' not in p.parts and p.resolve() != Path(__file__).resolve()]:
roots = [Path("src"), Path("tests"), Path("docs"), Path(".github")]
paths = [Path("README.md"), Path("CHANGELOG.md"), Path("pyproject.toml")]
paths.extend(path for root in roots for path in root.rglob("*") if path.is_file())
for path in paths:
if path.resolve() == Path(__file__).resolve():
continue
if path.suffix not in {'.py', '.md', '.toml', '.yml', '.yaml'}:
continue
for lineno, line in enumerate(path.read_text(errors='ignore').splitlines(), 1):
Expand Down
Loading
Loading