Reusable, technology-agnostic notification & event bus for FastAPI + Postgres with identity-aware real-time delivery.
Project description
notifika
A reusable, technology-agnostic notification & event bus for Python, with identity-aware real-time delivery. Postgres + Keycloak are the preferred adapters, but the core depends only on Protocol ports, so any backend fits.
Status: MVP (
v0.1, in development). Implemented and tested: dependency-free core, in-memory adapters, an optional FastAPI/SSE integration, and the preferred-stack adapters — Postgres (NotificationStore, cachedPolicyStore,LISTEN/NOTIFYEventBus), Keycloak (IdentityResolver), and a multi-worker SSE relay (RelayRealtimePush).
What it does
Match an event against configurable policies, resolve recipients via an identity provider (e.g. Keycloak groups/roles), and deliver to those users over pluggable channels — including a real-time in-app channel (SSE) for live UI toasts. Example: "when an L1 group performs an action, only the L2 group sees the toast." That routing is just a policy — fully configurable at runtime, not hardcoded.
The gap it fills: existing Postgres-native Python libraries are single-consumer job queues; the event-driven libraries with great DX don't support a Postgres/Keycloak-first, identity-aware notification model. notifika targets that intersection at low-to-moderate (human-driven) volume.
Architecture
Ports & adapters. The dependency-free core owns the domain:
- Models —
Event(a generic, owned envelope;Event.from_audit_event()adapts a duck-typed audit event),NotificationPolicy,Target,Notification. - Logic —
PolicyEngine(match),NotifyService(publish → resolve → dispatch),Dispatcher(persist → deliver, failures isolated per channel). - Ports (
notifika.ports) —EventBus,IdentityResolver,NotificationStore,NotificationChannel,RealtimePush,PolicyStore/MutablePolicyStore.
Adapters are optional extras. In-memory implementations ship for tests and
single-process use; the FastAPI/SSE integration lives behind the sse extra.
pip install notifika # core only (zero deps)
pip install "notifika[sse]" # + FastAPI SSE integration
pip install "notifika[postgres,keycloak,sse]" # full preferred stack (planned adapters)
Quickstart
One call wires the whole in-memory stack (demos, tests, single-process apps):
from notifika import Event, NotificationPolicy, Target, in_memory
nk = in_memory(
groups={"/ops/l2": ["alice", "bob"]},
policies=[NotificationPolicy.on("contract.updated", Target.group("/ops/l2"))],
)
queue = nk.connect("alice") # what an SSE endpoint opens per connection
await nk.publish(Event(type="contract.updated", actor_id="carol")) # an L1 user acts...
msg = await queue.get() # ...only the L2 group gets the live toast
in_memory(...) returns an InMemoryNotifika bundle exposing the wired
.service plus the adapters it was built from — .realtime, .store,
.policies, .resolver — so tests can drive them. There's exactly one publish
path: nk.publish(...) forwards to service.publish(Event(...)).
…or wire the object graph yourself (what in_memory does under the hood)
from notifika import (
Dispatcher, Event, InAppChannel, InMemoryIdentityResolver,
InMemoryNotificationStore, InMemoryPolicyStore, InMemoryRealtimePush,
NotificationPolicy, NotifyService, PolicyEngine, Target,
)
realtime = InMemoryRealtimePush()
resolver = InMemoryIdentityResolver(groups={"/ops/l2": ["alice", "bob"]})
store = InMemoryNotificationStore()
policies = InMemoryPolicyStore([
NotificationPolicy(
name="l1-action-escalates-to-l2",
match={"type": "contract.updated"},
targets=(Target(kind="group", value="/ops/l2", channels=("in_app",)),),
)
])
service = NotifyService(
policy_store=policies,
engine=PolicyEngine(),
resolver=resolver,
dispatcher=Dispatcher({"in_app": InAppChannel(realtime)}, store=store, resolver=resolver),
)
# An L1 user acts -> only the L2 group is notified in real time.
await service.publish(Event(type="contract.updated", actor_id="carol", payload={"resource_id": "42"}))
For AI agents & coding assistants
The library carries its own orientation — AGENTS.md — and
ships it inside the wheel (installed at <site-packages>/notifika/AGENTS.md).
So it reaches you with no docs site and no network, even from an airgapped
Nexus PyPI mirror:
python -m notifika # prints the AGENTS.md guide
python -c "import notifika; print(notifika.overview())"
AGENTS.md is the single source of truth: the mental model
(Event → policy match → resolve recipients → dispatch → channel), the one-call
quickstart, the ergonomic constructors, the production adapter swap, and what the
library deliberately does not do. notifika.overview() returns its text and
tests/guide_test.py executes its quickstart, so it can't drift from the API.
Everything is py.typed; help(notifika) / help(notifika.NotifyService)
expand each piece.
Production adapters (Postgres + Keycloak)
Swap the in-memory adapters for the preferred stack — same ports, so nothing in your wiring logic changes:
import asyncpg
from notifika import Dispatcher, InAppChannel, NotifyService, PolicyEngine, RelayRealtimePush, InMemoryRealtimePush
from notifika.postgres import PostgresNotificationStore, PostgresPolicyStore, PgListenNotifyBus
from notifika.keycloak import KeycloakIdentityResolver
pool = await asyncpg.create_pool(dsn)
store = PostgresNotificationStore(pool)
policies = PostgresPolicyStore(pool) # cached: sync reads, async upsert/delete/refresh
await store.ensure_schema(); await policies.ensure_schema(); await policies.refresh()
resolver = KeycloakIdentityResolver(
server_url="https://kc.example.com", realm="app",
client_id="notifika-svc", client_secret=secret, # service account (client_credentials)
)
# Multi-worker real-time: a push on any worker reaches the user's SSE connection
# on whichever worker holds it, via Postgres LISTEN/NOTIFY.
bus = PgListenNotifyBus(pool, connect=lambda: asyncpg.connect(dsn))
realtime = RelayRealtimePush(InMemoryRealtimePush(), bus)
await realtime.start() # stop() on shutdown
service = NotifyService(
policy_store=policies, engine=PolicyEngine(), resolver=resolver,
dispatcher=Dispatcher({"in_app": InAppChannel(realtime)}, store=store, resolver=resolver),
)
PgListenNotifyBuspayloads cross PostgresNOTIFY(< 8000 bytes) — it carries compact refs (user id, event type, small payloads), never large bodies. In a multi-worker deployment, broadcast a "policies changed" signal over the bus and callpolicies.refresh()so every worker's policy cache converges.
Adding channels (email, SMS, webhook, …)
A channel is any object implementing the NotificationChannel port — a
channel_name and async send(recipient_id, contact, payload). notifika resolves
contact for you via IdentityResolver.get_user_contact(user_id, channel) (the
Keycloak adapter returns the user's email for "email", or a matching attribute
such as a phone number for "sms"), then the Dispatcher hands it to your channel.
InAppChannel is the reference implementation.
class EmailChannel:
channel_name = "email"
def __init__(self, smtp): self._smtp = smtp
async def send(self, recipient_id, contact, payload):
if not contact:
raise ValueError(f"no email on file for {recipient_id}") # MUST raise on permanent failure
await self._smtp.send(to=contact, subject=payload["event_type"], body=render(payload))
# register it, then reference the name from a policy target:
dispatcher = Dispatcher({"in_app": InAppChannel(realtime), "email": EmailChannel(smtp)}, store=store, resolver=resolver)
# Target(kind="group", value="/ops/l2", channels=("in_app", "email"))
Guidance: raise on permanent failure so the Dispatcher marks the notification
failed (delivery is already failure-isolated per channel); keep send quick and
idempotent (a payload may be re-sent on retry); SMS/push/webhook are identical —
implement send, resolve the address via get_user_contact, register under a
channel name, list that name in the target's channels. Automatic retry of
failed/transient deliveries belongs in a worker over NotificationStore.get_pending
(roadmap, not yet built).
Configuring policies at runtime
NotifyService re-reads the policy store on every publish, so changes apply
live. The library provides the building blocks for a management surface —
MutablePolicyStore (upsert/get/delete) and policy
(de)serialization (NotificationPolicy.from_dict / .to_dict) — so an
application can expose policy CRUD however it likes.
notifika deliberately does not ship policy-CRUD HTTP routes. Editing
"who-gets-notified-about-what" is privileged, and the authorization model is
application-specific; an opinionated router would also freeze HTTP shape into the
library's public API. So your app owns that route and its admin gate — it's ~15
lines on top of the building blocks (see examples/fastapi_app.py):
from fastapi import APIRouter, Body, Depends
admin = APIRouter(dependencies=[Depends(require_admin)]) # YOUR admin gate (fail-closed)
@admin.post("/notification-policies", status_code=201)
async def create_policy(body: dict = Body(...)):
policies.upsert(NotificationPolicy.from_dict(body)) # MutablePolicyStore + serialization
return {"ok": True}
notifika does ship an optional, low-stakes router for the per-user read path (SSE stream + list/mark-read your own notifications):
from notifika.asgi import build_router
app.include_router(build_router(realtime=realtime, store=store, current_user=current_user))
Because the browser
EventSourceAPI can't send anAuthorizationheader, present the Keycloak token to the SSE stream via an HttpOnly cookie or a fetch-based SSE client; resolve it incurrent_user.
Development
pip install pytest
cd notifika && python -m pytest # core tests need no extra deps
pip install "fastapi" "sse-starlette" httpx && python -m pytest # incl. FastAPI tests
Tests use plain pytest + asyncio.run() (no pytest-asyncio), matching the
repo's house style. Adapter unit tests run against fakes (no live services).
Real-database integration tests are skipped unless NOTIFIKA_TEST_DSN is set.
Run them against a throwaway Postgres via the bundled compose file:
docker compose run --rm tests # spins up postgres:16, runs tests/integration_test.py
docker compose down -v
Or against any reachable Postgres directly:
NOTIFIKA_TEST_DSN=postgresql://user:pw@localhost:5432/db \
pip install asyncpg && python -m pytest tests/integration_test.py
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file notifika-0.1.1.tar.gz.
File metadata
- Download URL: notifika-0.1.1.tar.gz
- Upload date:
- Size: 40.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
76ded8388e7341698f1d75c1d6fa3f159ca168d2dcc5b74b977dade00d09ecc2
|
|
| MD5 |
5158a16a107ddfaa11f7095d2b5e447b
|
|
| BLAKE2b-256 |
83031c349c46c030f75314703ad4734f60a7d0db93de5433537aa5f001c1da4b
|
Provenance
The following attestation bundles were made for notifika-0.1.1.tar.gz:
Publisher:
release.yml on vanmarkic/audit-logger
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
notifika-0.1.1.tar.gz -
Subject digest:
76ded8388e7341698f1d75c1d6fa3f159ca168d2dcc5b74b977dade00d09ecc2 - Sigstore transparency entry: 2060484282
- Sigstore integration time:
-
Permalink:
vanmarkic/audit-logger@3ac6823128c64ab93b2487aac60a0315824964f9 -
Branch / Tag:
refs/tags/notifika-v0.1.1 - Owner: https://github.com/vanmarkic
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@3ac6823128c64ab93b2487aac60a0315824964f9 -
Trigger Event:
push
-
Statement type:
File details
Details for the file notifika-0.1.1-py3-none-any.whl.
File metadata
- Download URL: notifika-0.1.1-py3-none-any.whl
- Upload date:
- Size: 32.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aa52e82e0ce21d68248882275e19a92ecc025e3cd1a94cde2a5f0a30e723ae94
|
|
| MD5 |
3d77ced203dfcf5101245c7b8231b4d2
|
|
| BLAKE2b-256 |
036ca592641eef4e7e156821045c799e042b1cb7bb8ae7b6b3ae75f8d3beecf9
|
Provenance
The following attestation bundles were made for notifika-0.1.1-py3-none-any.whl:
Publisher:
release.yml on vanmarkic/audit-logger
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
notifika-0.1.1-py3-none-any.whl -
Subject digest:
aa52e82e0ce21d68248882275e19a92ecc025e3cd1a94cde2a5f0a30e723ae94 - Sigstore transparency entry: 2060484611
- Sigstore integration time:
-
Permalink:
vanmarkic/audit-logger@3ac6823128c64ab93b2487aac60a0315824964f9 -
Branch / Tag:
refs/tags/notifika-v0.1.1 - Owner: https://github.com/vanmarkic
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@3ac6823128c64ab93b2487aac60a0315824964f9 -
Trigger Event:
push
-
Statement type: