"""Provides the base event class."""
from typing import Optional, Callable, Tuple, Iterable, List, ClassVar, Mapping
from collections import defaultdict
from datetime import datetime
import hashlib
import copy
from pytz import UTC
from functools import wraps
from flask import current_app
from dataclasses import field, asdict
from .util import dataclass
from arxiv.base import logging
from arxiv.base.globals import get_application_config
from ..agent import Agent, System, agent_factory
from ...exceptions import InvalidEvent
from ..util import get_tzaware_utc_now
from ..submission import Submission
from .versioning import EventData, map_to_current_version
logger = logging.getLogger(__name__)
logger.propagate = False
Events = Iterable['Event']
Condition = Callable[['Event', Submission, Submission], bool]
Callback = Callable[['Event', Submission, Submission, Agent], Events]
Decorator = Callable[[Callable], Callable]
Rule = Tuple[Condition, Callback]
Store = Callable[['Event', Submission, Submission], Tuple['Event', Submission]]
[docs]class EventType(type):
"""Metaclass for :class:`.Event`\."""
[docs]@dataclass()
class Event(metaclass=EventType):
"""
Base class for submission-related events/commands.
An event represents a change to a :class:`.domain.submission.Submission`.
Rather than changing submissions directly, an application should create
(and store) events. Each event class must inherit from this base class,
extend it with whatever data is needed for the event, and define methods
for validation and projection (changing a submission):
- ``validate(self, submission: Submission) -> None`` should raise
:class:`.InvalidEvent` if the event instance has invalid data.
- ``project(self, submission: Submission) -> Submission`` should perform
changes to the :class:`.domain.submission.Submission` and return it.
An event class also provides a hook for doing things automatically when the
submission changes. To register a function that gets called when an event
is committed, use the :func:`bind` method.
"""
creator: Agent
"""
The agent responsible for the operation represented by this event.
This is **not** necessarily the creator of the submission.
"""
created: Optional[datetime] = field(default=None) # get_tzaware_utc_now
"""The timestamp when the event was originally committed."""
proxy: Optional[Agent] = field(default=None)
"""
The agent who facilitated the operation on behalf of the :attr:`.creator`.
This may be an API client, or another user who has been designated as a
proxy. Note that proxy implies that the creator was not directly involved.
"""
client: Optional[Agent] = field(default=None)
"""
The client through which the :attr:`.creator` performed the operation.
If the creator was directly involved in the operation, this property should
be the client that facilitated the operation.
"""
submission_id: Optional[int] = field(default=None)
"""
The primary identifier of the submission being operated upon.
This is defined as optional to support creation events, and to facilitate
chaining of events with creation events in the same transaction.
"""
committed: bool = field(default=False)
"""
Indicates whether the event has been committed to the database.
This should generally not be set from outside this package.
"""
before: Optional[Submission] = None
"""The state of the submission prior to the event."""
after: Optional[Submission] = None
"""The state of the submission after the event."""
event_type: str = field(default_factory=str)
event_version: str = field(default_factory=str)
_hooks: ClassVar[Mapping[type, List[Rule]]] = defaultdict(list)
def __post_init__(self):
"""Make sure data look right."""
self.event_type = self.get_event_type()
self.event_version = self.get_event_version()
if self.client and type(self.client) is dict:
self.client = agent_factory(**self.client)
if self.creator and type(self.creator) is dict:
self.creator = agent_factory(**self.creator)
if self.proxy and type(self.proxy) is dict:
self.proxy = agent_factory(**self.proxy)
if self.before and type(self.before) is dict:
self.before = Submission(**self.before)
if self.after and type(self.after) is dict:
self.after = Submission(**self.after)
[docs] @staticmethod
def get_event_version() -> str:
return get_application_config().get('CORE_VERSION', '0.0.0')
[docs] @classmethod
def get_event_type(cls) -> str:
"""Get the name of the event type."""
return cls.__name__
@property
def event_id(self) -> str:
"""Unique ID for this event."""
if not self.created:
raise RuntimeError('Event not yet committed')
return self.get_id(self.created, self.event_type, self.creator)
[docs] @staticmethod
def get_id(created: datetime, event_type: str, creator: Agent) -> str:
h = hashlib.new('sha1')
h.update(b'%s:%s:%s' % (created.isoformat().encode('utf-8'),
event_type.encode('utf-8'),
creator.agent_identifier.encode('utf-8')))
return h.hexdigest()
[docs] def apply(self, submission: Optional[Submission] = None) -> Submission:
"""Apply the projection for this :class:`.Event` instance."""
self.before = copy.deepcopy(submission)
self.validate(submission)
if submission is not None:
self.after = self.project(copy.deepcopy(submission))
else:
logger.debug('Submission is None; project without submission.')
self.after = self.project()
self.after.updated = self.created
# Make sure that the submission has its own ID, if we know what it is.
if self.after.submission_id is None and self.submission_id is not None:
self.after.submission_id = self.submission_id
if self.submission_id is None and self.after.submission_id is not None:
self.submission_id = self.after.submission_id
return self.after
[docs] @classmethod
def bind(cls, condition: Optional[Condition] = None) -> Decorator:
"""
Generate a decorator to bind a callback to an event type.
To register a function that will be called whenever an event is
committed, decorate it like so:
.. code-block:: python
@MyEvent.bind()
def say_hello(event: MyEvent, before: Submission,
after: Submission, creator: Agent) -> Iterable[Event]:
yield SomeOtherEvent(...)
The callback function will be passed the event that triggered it, the
state of the submission before and after the triggering event was
applied, and a :class:`.System` agent that can be used as the creator
of subsequent events. It should return an iterable of other
:class:`.Event` instances, either by yielding them, or by
returning an iterable object of some kind.
By default, callbacks will only be called if the creator of the
trigger event is not a :class:`.System` instance. This makes it less
easy to define infinite chains of callbacks. You can pass a custom
condition to the decorator, for example:
.. code-block:: python
def jill_created_an_event(event: MyEvent, before: Submission,
after: Submission) -> bool:
return event.creator.username == 'jill'
@MyEvent.bind(jill_created_an_event)
def say_hi(event: MyEvent, before: Submission,
after: Submission, creator: Agent) -> Iterable[Event]:
yield SomeOtherEvent(...)
Note that the condition signature is ``(event: MyEvent, before:
Submission, after: Submission) -> bool``\.
Parameters
----------
condition : Callable
A callable with the signature ``(event: Event, before: Submission,
after: Submission) -> bool``. If this callable returns ``True``,
the callback will be triggered when the event to which it is bound
is saved. The default condition is that the event was not created
by :class:`System`
Returns
-------
Callable
Decorator for a callback function, with signature ``(event: Event,
before: Submission, after: Submission, creator: Agent =
System(...)) -> Iterable[Event]``.
"""
if condition is None:
def _creator_is_not_system(e: Event, *args, **kwargs) -> bool:
return type(e.creator) is not System
condition = _creator_is_not_system
def decorator(func: Callback) -> Callback:
"""Register a callback for an event type and condition."""
name = f'{cls.__name__}::{func.__module__}.{func.__name__}'
sys = System(name)
setattr(func, '__name__', name)
@wraps(func)
def do(event: Event, before: Submission, after: Submission,
creator: Agent = sys, **kwargs) -> Iterable['Event']:
"""Perform the callback. Here in case we need to hook in."""
return func(event, before, after, creator, **kwargs)
cls._add_callback(condition, do)
return do
return decorator
@classmethod
def _add_callback(cls: type, condition: Condition,
callback: Callback) -> None:
cls._hooks[cls].append((condition, callback))
def _get_callbacks(self) -> List[Tuple[Condition, Callback]]:
return ((condition, callback) for cls in type(self).__mro__[::-1]
for condition, callback in self._hooks[cls])
def _should_apply_callbacks(self) -> bool:
config = get_application_config()
return bool(int(config.get('ENABLE_CALLBACKS', '0')))
[docs] def commit(self, store: Store) -> Tuple[Submission, Events]:
"""
Persist this event instance using an injected store method.
Parameters
----------
save : Callable
Should have signature ``(*Event, submission_id: int) ->
Tuple[Event, Submission]``.
Returns
-------
:class:`Submission`
State of the submission after storage. Some changes may have been
made to ensure consistency with the underlying datastore.
list
Items are :class:`Event` instances.
"""
_, after = store(self, self.before, self.after)
self.committed = True
if not self._should_apply_callbacks():
return self.after, []
consequences: List[Event] = []
for condition, callback in self._get_callbacks():
if condition(self, self.before, self.after):
for consequence in callback(self, self.before, self.after):
consequence.created = datetime.now(UTC)
self.after = consequence.apply(self.after)
consequences.append(consequence)
self.after, addl_consequences = consequence.commit(store)
for addl in addl_consequences:
consequences.append(addl)
return self.after, consequences
def _get_subclasses(klass: type) -> List[type]:
_subclasses = klass.__subclasses__()
if _subclasses:
return _subclasses + [sub for klass in _subclasses
for sub in _get_subclasses(klass)]
return _subclasses
[docs]def event_factory(**data: EventData) -> Event:
"""
Generate an :class:`Event` instance from raw :const:`EventData`.
Parameters
----------
event_type : str
Should be the name of a :class:`.Event` subclass.
data : kwargs
Keyword parameters passed to the event constructor.
Returns
-------
:class:`.Event`
An instance of an :class:`.Event` subclass.
"""
etypes = {klas.get_event_type(): klas for klas in _get_subclasses(Event)}
data = map_to_current_version(data)
event_type = data.pop("event_type")
event_version = data.pop("event_version")
logger.debug('Create %s with data version %s', event_type, event_version)
if 'created' not in data:
data['created'] = datetime.now(UTC)
if event_type in etypes:
return etypes[event_type](**data)
raise RuntimeError('Unknown event type: %s' % event_type)