Skip to content

events — API Reference ​

python
from fastapi_viewsets.events import (
    Change,
    ChangeEvents,
    DeclaredSources,
    Event,
    EventsViewSet,
    MemoryEventStore,
    RecordsChanged,
    Source,
    Subscription,
    ViewsetTopics,
    allow_all,
    allow_authenticated,
    check_events,
    publish,
    register_events,
    source,
    start_events,
    stop_events,
)
from fastapi_viewsets.events.redis import RedisEventStore

The behaviour is described in Events.

Events and publishing ​

Event ​

python
class Event(BaseModel):
    event_name: ClassVar[str]
    event_version: ClassVar[int] = 1

The base of every event. A subclass sets event_name and declares the payload as pydantic fields. event_version is the version of the payload's shape: 1 unless the subclass sets a larger int, inherited by a subclass. It travels with every push and every poll entry, so a consumer tells the shapes of one event's payload apart and handles several versions side by side. A subclass without a non-empty string event_name, or with an event_version that is not an int of at least 1 (a bool is not), raises TypeError when the class is created. Both are class attributes, set by plain assignment or as a ClassVar; a subclass that annotates either one as a field (event_version: int = 2) raises TypeError when the class is created. The payload is model_dump(mode="json").

Functions ​

NameDescription
async publish(topic: str, event: Event) -> tuple[int, int]Validates topic, records the event's event_name, payload and event_version with store.append, carries it to every process with store.publish, and returns (rev, prev). Raises ValueError for a topic of neither form and TypeError for an event that is not an Event; an exception of store.publish is logged at warning level and not raised. OverflowError of store.append propagates
source(name: str, authorizer: EventAuthorizer | None = None) -> SourceSource(name, authorizer)
declared_source(name: str) -> Source | NoneThe source declared as name in this process
reset_sources() -> NoneForgets every declared source; for tests

Source ​

python
class Source:
    def __init__(self, name: str, authorizer: EventAuthorizer | None = None): ...

The declared owner of the topics name and name:key. Creating one registers name in the process; a name that does not match [a-z0-9_.-]+, or one already declared, raises ValueError.

MemberDescription
nameThe source's name
authorizerThe EventAuthorizer DeclaredSources() consults for the source's topics, or None
topic(key: str | int | None = None) -> strname:key, or name without a key. Raises ValueError when the result is not a topic
async publish(event: Event, key: str | int | None = None) -> tuple[int, int]publish(self.topic(key), event)
async drop(key: str | int) -> NoneRemoves the rev and the log of name:key from the store; its next event, if any, carries prev 0

Topics ​

NameDescription
SOURCE_NAME_PATTERNre.compile(r"[a-z0-9_.-]+"), the form of a source name, matched in full
is_viewset_topic(topic: Any) -> boolWhether topic is a string of the viewset form: / followed by characters other than whitespace and control characters
is_valid_topic(topic: Any) -> boolWhether topic has the viewset form or the custom form, name or name:key with a non-empty key without whitespace or control characters
validate_topic(topic: Any) -> strtopic; raises ValueError when it has neither form
split_custom_topic(topic: str) -> tuple[str, str | None] | None(name, key) of a custom topic, key None without one; None for a topic of any other form

The store ​

EventStore ​

python
@runtime_checkable
class EventStore(Protocol):
    async def start(self) -> None: ...
    async def stop(self) -> None: ...
    async def append(self, topic: str, name: str, payload: Any, version: int) -> tuple[int, int]: ...
    async def revs(self, topics: list[str]) -> dict[str, int]: ...
    async def since(self, topic: str, after: int) -> list[LoggedEvent] | None: ...
    async def publish(self, topic: str, rev: int, prev: int, name: str, payload: Any, version: int) -> None: ...
    async def drop(self, topic: str) -> None: ...
MethodContract
start()Starts what the store needs to carry events to this process
stop()Stops what start() started
append(topic, name, payload, version)Sets the topic's rev to max(rev + 1, now in milliseconds), keeps the event's name, payload and version in the topic's log and returns (rev, prev). Atomic; raises OverflowError when the rev would reach MAX_REV
revs(topics)The current rev of each topic, 0 for a topic without one
since(topic, after)[] when the topic's rev is after; otherwise the kept event whose prev is after and every newer one, oldest first; None when no kept event has that prev
publish(topic, rev, prev, name, payload, version)Carries the event to deliver(topic, rev, prev, name, payload, version) in every process whose store is started; deliver() pushes it to the topic's subscriptions there
drop(topic)Removes the topic's rev and log

LoggedEvent ​

python
class LoggedEvent(NamedTuple):
    rev: int
    prev: int
    name: str
    payload: Any
    version: int

One event of a topic's log. prev is the topic's rev before the event, 0 for a topic without one; version is the event's event_version.

MemoryEventStore ​

python
class MemoryEventStore:
    def __init__(self) -> None: ...

The event store of one process. publish calls deliver() in this process only. A topic's log holds its last settings.viewsets_event_log_size events and is emptied when it is read or appended to more than settings.viewsets_event_log_ttl seconds after the topic's last event. A topic's rev is kept until drop. append reads and writes the rev without an await in between. start and stop do nothing.

RedisEventStore ​

python
from fastapi_viewsets.events.redis import RedisEventStore

class RedisEventStore:
    def __init__(self, redis_client: redis.asyncio.Redis, key_prefix: str = "events:"): ...

An event store shared by every process that reaches one Redis. fastapi_viewsets.events.redis is the only module of the package that uses redis; fastapi_viewsets.events does not import it.

ParameterTypeDefaultDescription
redis_clientredis.asyncio.Redis—The client the application constructs
key_prefixstr"events:"The prefix of the keys and the channel
MemberDescription
rev_key(topic) -> str{prefix}rev:{topic}: the topic's rev, a decimal string without expiry
log_key(topic) -> str{prefix}log:{topic}: the topic's log, a list of JSON entries {"rev", "prev", "event": {"name", "payload", "version"}}, newest first, at most viewsets_event_log_size long, expiring viewsets_event_log_ttl seconds after the topic's last event (PEXPIRE, at least 1 ms). A log size of 0 deletes the log
channel: str{prefix}events: the one channel publish publishes every event on, as the JSON object {"topic", "rev", "prev", "name", "payload", "version"}
reader_running: boolWhether the reader task runs
async start()SUBSCRIBE of channel on one pub/sub connection; once the reader has read Redis's confirmation, starts the reader, one task that calls deliver() for each message. Raises asyncio.TimeoutError when the confirmation does not arrive within 5 seconds, and the connection's error when it fails; the connection is then closed and the store not started. A store whose reader runs returns at once
async stop()Cancels the reader and closes its connection
async append(topic, name, payload, version)One Lua script: the rev with GET and SET, the log with LPUSH, LTRIM and PEXPIRE. Raises OverflowError at MAX_REV, changing nothing
async revs(topics)MGET; an absent key is 0
async since(topic, after)GET of the rev, then LRANGE of the log
async publish(topic, rev, prev, name, payload, version)PUBLISH on channel
async drop(topic)DEL of the rev and the log

Every process whose store is started receives every event, and deliver() leaves out a topic without a subscription in the process; no event reaches a socket that does not hold its topic. The reader logs a failed read and retries after one second, and logs and skips a message that cannot be delivered. When redis-py reconnects the pub/sub connection it subscribes channel again; an event published while the connection is down does not reach the process, and the event client finds it by poll, on the topic's next push or its check poll.

Functions and constants ​

NameDescription
get_store() -> EventStoresettings.viewsets_event_store, or this process's one MemoryEventStore, created on first use, while that is None
MAX_REV2**53, the bound every rev stays below
next_rev(prev: int, now_ms: int) -> intmax(prev + 1, now_ms); raises OverflowError when that reaches MAX_REV. In fastapi_viewsets.events.store

Lifespan ​

NameDescription
async start_events() -> Noneawait get_store().start()
async stop_events() -> Noneawait get_store().stop()
check_events() -> boolLogs a warning naming await start_events() and returns False when the store's reader_running is False (a RedisEventStore before start_events()); returns True otherwise

Subscriptions and delivery ​

Subscription ​

python
@dataclass(frozen=True, eq=False)
class Subscription:
    peer: Any | None
    context: Context
    topic: str

One socket following one topic. peer is the muxws peer of the socket, context the context of the subscribe call that made the subscription. For the delivery filters of a poll, the poll of one topic: peer is the socket the poll arrived on, None for a poll over REST, and context the context of the poll call.

DeliveryFilter ​

python
DeliveryFilter = Callable[[Subscription, str, int, str, Any, int], bool | Awaitable[bool]]

filter(subscription, topic, rev, event_name, payload, version), sync or async; version is the event's event_version. The filters run on every push and on every event a poll answers after the cursor. A result that is False skips the push to the subscription's socket, or leaves the event out of the poll answer; any other result passes; an exception counts as True and is logged at debug level. On a poll over REST, subscription.peer is None.

Functions ​

NameDescription
async deliver(topic: str, rev: int, prev: int, name: str, payload: Any, version: int) -> NoneStarts one task per subscription of topic in this process and returns without waiting. A task runs the delivery filters, then peer.notify(payload, headers={":event": name, ":version": version, ":topic": topic, ":rev": rev, ":prev": prev}), together bounded by settings.viewsets_event_push_timeout seconds; muxws's notify() queues the push and does not wait for the socket. A ConnectionLost (matched by class name) drops every subscription of its socket; any other failure is logged at debug level
async passes_filters(subscription: Subscription, topic: str, rev: int, name: str, payload: Any, version: int) -> boolRuns settings.viewsets_event_delivery_filters in order and returns False once one of them returns a result that is False; a filter that raises counts as True and is logged at debug level. deliver and poll call it
async subscribe(peer: Any, context: Context, topic: str) -> SubscriptionSubscribes the socket to topic and returns the subscription; deliver() pushes the topic's events to the socket from then on. A topic the socket holds is subscribed again with context and counts once. Raises EventSubscriptionsExceededError when the socket already holds settings.viewsets_event_max_subscriptions other topics. The first subscription of a socket installs one peer.on_close handler, which calls drop_peer(peer). For a socket whose is_open is false it returns the subscription without entering it in the table
async unsubscribe(peer: Any, topic: str) -> NoneRemoves the socket's subscription to topic, if it holds one
drop_peer(peer: Any) -> NoneRemoves every subscription of the socket
subscriptions(topic: str) -> list[Subscription]The subscriptions of topic in this process
topics_of(peer: Any) -> list[str]The topics the socket holds
async wait_for_pushes() -> NoneWaits until the background tasks of the delivery module (pushes) running now and started meanwhile are done
reset_subscriptions() -> NoneForgets every subscription of this process; for tests

Authorization ​

EventAuthorizer ​

python
EventAuthorizer = Callable[[Request, Context, str], Awaitable[bool | None]]

await authorizer(request, context, topic): True admits the topic, False refuses it, None leaves the decision to the next authorizer in settings.viewsets_event_authorizers. An HTTPException it raises, FastAPI's or Starlette's, refuses the topic with that exception.

Authorizers and functions ​

NameDescription
async authorize_topic(request: Request, context: Context, topic: str) -> NoneRuns settings.viewsets_event_authorizers in order and returns at the first True. The first False, or no decision, raises EventTopicRefusedError. An exception an authorizer raises propagates. An authorizer may return its decision without an awaitable
async allow_all(request, context, topic) -> boolTrue
async allow_authenticated(request, context, topic) -> bool | NoneTrue when await context.user is truthy; None otherwise and for a context without user
DeclaredSources()async __call__(request, context, topic) -> bool | None: the answer of the authorizer of the source that declared the topic's name; None for a viewset topic, an undeclared name, and a source without an authorizer
ViewsetTopics()async __call__(request, context, topic) -> bool | None: None for a topic that does not start with / and for one no viewset on request.app has (match_viewset). Otherwise the checks run against request carrying the topic's path parameters as path_params: the route's dependencies other than route_viewset's own (those of the application, of include_router, of the APIRouter and of the route), resolved as FastAPI resolves them, False when a parameter of one does not validate; then the found action's @action_configuration is resolved, the context rebound as Context(context.raw(), action_configuration=..., action_name=...), and depends(request, cls, context) of every Middleware in settings.viewsets_command_middleware and of the instances the configuration injects runs. Returns True when none raises. An HTTPException propagates
match_viewset(app, topic: str) -> ViewsetMatch | NoneIn fastapi_viewsets.events.authorizers. The first route on app in routing order, include_router prefixes included, whose endpoint carries __viewset_class__ and, as __viewset_action__, the class's list_items, or retrieve for a class without list_items, and whose path without that action's __viewset_metadata__["paths"] entry matches topic as Starlette matches a request path, path parameters included. Routes of an application under a Starlette Mount are not searched
ViewsetMatchIn fastapi_viewsets.events.authorizers. A frozen dataclass: viewset_class: type, action: str, route (the application's route of action) and path_params: dict[str, Any], the values of the routed base path's path parameters read from the topic and converted by their convertors
find_viewset(app, topic: str) -> tuple[type, str] | NoneIn fastapi_viewsets.events.authorizers. (viewset_class, action) of match_viewset(app, topic), or None

The events viewset ​

NameDescription
register_events(router: APIRouter, base_path: str = "/events") -> type[EventsViewSet]Decorates a new subclass of EventsViewSet with route_viewset(router, base_path) and returns it
EventsViewSetThe viewset register_events mounts. Not decorated itself
EndpointTransportsBodyAnswerErrors
subscribe — POST {base_path}/subscribemuxwsTopicRequest200 SubscribeAnswer422 event_topic_invalid, 403 event_topic_refused or the authorizer's HTTPException, 409 event_subscriptions_exceeded, RuntimeError (500) without peer=
unsubscribe — POST {base_path}/unsubscribemuxwsTopicRequest204RuntimeError (500) without peer=
poll — POST {base_path}/pollREST, muxwsPollRequest200 PollAnswer422 event_cursors_exceeded; a topic's refusal is in its TopicAnswer.error

subscribe and unsubscribe read the socket from request.scope["fastapi_viewsets.peer"] (fastapi_viewsets.routing.PEER_SCOPE_KEY), set by process_command(..., peer=peer). poll reads it from there for the Subscription its delivery filters receive, None when the scope has none.

The models, in fastapi_viewsets.events.viewset:

ModelFields
TopicRequesttopic: str
SubscribeAnswerrev: int, the topic's current rev, 0 for a topic without events
PollRequestcursors: dict[str, int | None], each topic mapped to the rev the caller holds, None to start from the current rev
PolledEventrev: int, prev: int (the rev of the event before it in the answer, the cursor for the first), event: str (the event_name), version: int (the event_version, at least 1), payload: Any = None
TopicAnswerrev: int | None, events: list[PolledEvent] | None (the events after the cursor the delivery filters pass), error: str | None = None; rev covers the events the filters left out; error is left out of the serialized answer when it is None
PollAnswertopics: dict[str, TopicAnswer], one per cursor in the request's order; poll_after_ms: int, settings.viewsets_event_poll_after_ms

ChangeEvents ​

python
class ChangeEvents(Middleware): ...

A Middleware. modifies_response_shape is False.

MethodDescription
async __call__(request, viewset, context, call_next) -> ViewSetResultStores routed_base_path(request) in the context under TOPIC_CONTEXT_KEY when it is not None, then returns await call_next()
async execute(execution, call_next) -> AnyFor the eight write actions, unless disabled or replaced_for(execution): resolves and validates the topic and, for created and updated, the pk field, then runs call_next() and publishes one RecordsChanged. Returns the action's result. An exception of the publish, store.append's included, propagates after the action has run. Any other action, a disabled one and a replaced one pass through
replaced_for(execution) -> boolWhether the execution's action configuration has ChangeEvents instances as keys and this instance is not one of them
config_from(context) -> AnyThe value configured under this instance as the key, else under its class
options_for(context) -> dict[str, Any] | None{} for None and True, None for False, the dict for a dict with no key other than topic. Raises TypeError for any other value or key
topic_for(execution) -> strThe configured topic (a callable is called with execution); else, when execution.parent is None, context["fastapi_viewsets.events.topic"]; else __viewset_metadata__["base_path"]. Raises TypeError with none of the three
pk_field_for(execution) -> str__viewset_metadata__["pk_field_name"], else the viewset's pk_field_name, else its pk_field. Raises TypeError naming the viewset with none of the three
changes_for(execution, op: str, result: Any) -> list[Change]For deleted, one Change per pk of the pk argument (keyword or positional); otherwise one per returned record, its pk read from the pk_field_for field. A ViewSetResult contributes its body. A record without the pk field raises TypeError
execution.actionop
create, bulk_createcreated
update, partial_update, bulk_update, bulk_partial_updateupdated
destroy, bulk_destroydeleted

Configuration, read with config_from:

ValueEffect
None, TrueDefaults
FalseNo event
{"topic": str | Callable[[ActionExecution], str]}The topic to publish to

A ChangeEvents instance as a key injects that instance for the viewset or action; it reads the value under its own key, else under its class, and the ChangeEvents of settings.viewsets_command_middleware passes the action through (replaced_for).

Models and helpers ​

NameDescription
ChangeBaseModel: pk: Any. A frontend reads the changed record through the viewset's retrieve, which answers in the endpoint's format and in the reader's context
RecordsChangedEvent with event_name = "records_changed" and event_version = 1: op: Literal["created", "updated", "deleted"], changes: list[Change]
TOPIC_CONTEXT_KEY"fastapi_viewsets.events.topic"
routed_base_path(request: Request) -> str | NoneThe include_router prefixes followed by base_path of the viewset whose endpoint the request was routed to, with the values of the request's path parameters: the request's path relative to root_path, with as many trailing segments removed as __viewset_metadata__["paths"][action] has. None when the matched endpoint is not one route_viewset registered

Settings ​

AttributeTypeDefaultDescription
settings.viewsets_event_storeEventStore | NoneNoneThe store; None is one MemoryEventStore per process
settings.viewsets_event_authorizerslist[EventAuthorizer][]The authorizers, in order
settings.viewsets_event_delivery_filterslist[DeliveryFilter][]The delivery filters, in order; they run on every push and on every event a poll answers
settings.viewsets_event_log_sizeint32Events kept per topic for poll
settings.viewsets_event_log_ttlfloat60.0Seconds a topic's log is kept after its last event
settings.viewsets_event_push_timeoutfloat2.0Seconds the delivery filters and peer.notify() of one push to one socket may take together
settings.viewsets_event_max_subscriptionsint256Topics one socket may hold, and cursors one poll may carry
settings.viewsets_event_poll_after_msint0poll_after_ms of every poll answer

Errors ​

ClassStatuscodeparams
EventTopicInvalidError(topic)422event_topic_invalid{topic}
EventTopicRefusedError(topic)403event_topic_refused{topic}
EventSubscriptionsExceededError(limit)409event_subscriptions_exceeded{limit}
EventCursorsExceededError(count, limit)422event_cursors_exceeded{count, limit}

All four are DfViewSetErrors in fastapi_viewsets.exceptions; see Error Codes.

Released under the MIT License.