├── kant ├── datamapper │ ├── __init__.py │ ├── exceptions.py │ ├── models.py │ ├── base.py │ └── fields.py ├── eventstore │ ├── backends │ │ ├── __init__.py │ │ └── aiopg.py │ ├── __init__.py │ ├── connection.py │ ├── exceptions.py │ └── stream.py ├── events │ ├── __init__.py │ ├── exceptions.py │ └── base.py ├── aggregates │ ├── __init__.py │ ├── exceptions.py │ └── base.py ├── projections │ ├── __init__.py │ ├── exceptions.py │ ├── sa.py │ └── base.py ├── exceptions.py ├── __init__.py └── conftest.py ├── .coveragerc ├── AUTHORS ├── setup.py ├── docs ├── events.rst ├── testing.rst ├── commands.rst ├── _static │ └── logo.jpg ├── eventstore.rst ├── projections.rst ├── reference.rst ├── installation.rst ├── index.rst └── conf.py ├── Dockerfile ├── .gitignore ├── Pipfile ├── Makefile ├── .editorconfig ├── docker-compose.yml ├── tests ├── events │ └── test_events.py ├── eventstore │ ├── backends │ │ └── test_aiopg.py │ ├── test_stream.py │ └── test_eventstore.py ├── conftest.py ├── test_projections.py └── test_aggregates.py ├── CONTRIBUTING.rst ├── LICENSE.txt ├── tox.ini ├── .travis.yml ├── setup.cfg ├── README.md ├── CHANGELOG.md └── Pipfile.lock /kant/datamapper/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = tests/* -------------------------------------------------------------------------------- /kant/eventstore/backends/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Patrick Porto 2 | -------------------------------------------------------------------------------- /kant/events/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import * # NOQA 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /kant/aggregates/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import * # NOQA 2 | -------------------------------------------------------------------------------- /kant/datamapper/exceptions.py: -------------------------------------------------------------------------------- 1 | class FieldError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /docs/events.rst: -------------------------------------------------------------------------------- 1 | .. events: 2 | 3 | Events 4 | ============= 5 | 6 | **TODO** 7 | -------------------------------------------------------------------------------- /kant/aggregates/exceptions.py: -------------------------------------------------------------------------------- 1 | class AggregateError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /docs/testing.rst: -------------------------------------------------------------------------------- 1 | .. _testing: 2 | 3 | Testing 4 | ============= 5 | 6 | **TODO** 7 | -------------------------------------------------------------------------------- /docs/commands.rst: -------------------------------------------------------------------------------- 1 | .. _commands: 2 | 3 | Commands 4 | ============= 5 | 6 | **TODO** 7 | -------------------------------------------------------------------------------- /docs/_static/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickporto/kant/HEAD/docs/_static/logo.jpg -------------------------------------------------------------------------------- /docs/eventstore.rst: -------------------------------------------------------------------------------- 1 | .. _eventstore: 2 | 3 | Event Store 4 | ============= 5 | 6 | **TODO** 7 | -------------------------------------------------------------------------------- /kant/projections/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import * # NOQA 2 | from .exceptions import * # NOQA 3 | -------------------------------------------------------------------------------- /kant/eventstore/__init__.py: -------------------------------------------------------------------------------- 1 | from .stream import * # NOQA 2 | from .connection import * # NOQA 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.5 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY . . 6 | 7 | CMD python setup.py test 8 | -------------------------------------------------------------------------------- /docs/projections.rst: -------------------------------------------------------------------------------- 1 | .. _projections: 2 | 3 | Read model projections 4 | ====================== 5 | 6 | **TODO** 7 | -------------------------------------------------------------------------------- /kant/events/exceptions.py: -------------------------------------------------------------------------------- 1 | class EventDoesNotExist(Exception): 2 | pass 3 | 4 | 5 | class EventError(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /kant/projections/exceptions.py: -------------------------------------------------------------------------------- 1 | class ProjectionError(Exception): 2 | pass 3 | 4 | 5 | class ProjectionDoesNotExist(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__ 3 | /dist/ 4 | /*.egg-info 5 | /.eggs/ 6 | .tox 7 | .coverage 8 | .cache 9 | build 10 | _build 11 | /.mypy_cache/ 12 | /.pytest_cache/ 13 | .venv 14 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | .. _reference: 2 | 3 | API Reference 4 | ============= 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | :caption: Contents: 9 | 10 | .. automodule:: kant 11 | :members: 12 | -------------------------------------------------------------------------------- /kant/exceptions.py: -------------------------------------------------------------------------------- 1 | from .aggregates.exceptions import * # NOQA 2 | from .datamapper.exceptions import * # NOQA 3 | from .events.exceptions import * # NOQA 4 | from .eventstore.exceptions import * # NOQA 5 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.python.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [dev-packages] 7 | 8 | "a14ec56" = {path = ".", editable = true} 9 | sphinx = "*" 10 | "sphinx-autobuild" = "*" 11 | wheel = "*" 12 | tox = "*" 13 | detox = "*" 14 | 15 | [packages] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | 3 | build: 4 | python3 setup.py build 5 | 6 | install: 7 | python3 setup.py install 8 | 9 | publish: 10 | python3 setup.py sdist bdist_wheel upload 11 | 12 | test: 13 | detox 14 | 15 | docs-build: 16 | python3 setup.py build_sphinx 17 | 18 | docs-live: 19 | sphinx-autobuild docs _build/html 20 | -------------------------------------------------------------------------------- /kant/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | kant 3 | ~~~~~ 4 | A CQRS and Event Sourcing framework. 5 | 6 | :copyright: © 2018 by Patrick Porto. 7 | :license: MIT, see LICENSE for more details. 8 | """ 9 | import logging 10 | 11 | 12 | __version__ = "3.0.0" 13 | 14 | 15 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | 3 | # EditorConfig is awesome: http://EditorConfig.org 4 | 5 | # top-most EditorConfig file 6 | root = true 7 | 8 | # Unix-style newlines with a newline ending every file 9 | [*] 10 | end_of_line = lf 11 | insert_final_newline = true 12 | charset = utf-8 13 | 14 | [*.py] 15 | indent_style = space 16 | indent_size = 4 17 | 18 | [Makefile] 19 | indent_style = tab 20 | 21 | [{*.json,.*.yml}] 22 | indent_style = space 23 | indent_size = 2 24 | 25 | -------------------------------------------------------------------------------- /kant/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | 4 | from kant import events 5 | 6 | import pytest 7 | 8 | 9 | class FoundAdded(events.Event): 10 | amount = events.DecimalField() 11 | 12 | 13 | class AccountSchemaModel(events.SchemaModel): 14 | balance = events.DecimalField() 15 | 16 | 17 | @pytest.fixture(autouse=True) 18 | def event_model_example(doctest_namespace): 19 | doctest_namespace["FoundAdded"] = FoundAdded 20 | doctest_namespace["json"] = json 21 | doctest_namespace["datetime"] = datetime 22 | doctest_namespace["AccountSchemaModel"] = AccountSchemaModel 23 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | db: 5 | image: postgres 6 | ports: 7 | - "5432:5432" 8 | environment: 9 | - POSTGRES_USER=test 10 | - POSTGRES_PASSWORD=test 11 | - POSTGRES_DB=mydatabase 12 | web: 13 | build: . 14 | ports: 15 | - "8080:8080" 16 | volumes: 17 | - .:/usr/src/app 18 | depends_on: 19 | - db 20 | links: 21 | - db 22 | environment: 23 | - PYTHONUNBUFFERED=0 24 | - DATABASE_USER=test 25 | - DATABASE_PASSWORD=test 26 | - DATABASE_DATABASE=mydatabase 27 | - DATABASE_HOST=db 28 | -------------------------------------------------------------------------------- /kant/eventstore/connection.py: -------------------------------------------------------------------------------- 1 | from .backends.aiopg import EventStoreConnection 2 | 3 | _connection = None 4 | 5 | 6 | async def connect( 7 | dsn=None, user=None, password=None, host=None, database=None, *, pool=None 8 | ): 9 | global _connection 10 | settings = { 11 | "dsn": dsn, 12 | "user": user, 13 | "password": password, 14 | "host": host, 15 | "database": database, 16 | "pool": pool, 17 | } 18 | _connection = await EventStoreConnection.create(settings) 19 | return _connection 20 | 21 | 22 | def get_connection(): 23 | return _connection 24 | -------------------------------------------------------------------------------- /kant/datamapper/models.py: -------------------------------------------------------------------------------- 1 | from .base import FieldMapping, ModelMeta 2 | 3 | 4 | class SchemaModel(FieldMapping, metaclass=ModelMeta): 5 | 6 | @classmethod 7 | def make(self, obj, cls): 8 | Event = cls 9 | json_columns = {} 10 | for name, field in Event.concrete_fields.items(): 11 | json_column = field.json_column or name 12 | json_columns[json_column] = name 13 | args = {json_columns[name]: value for name, value in obj.items()} 14 | return Event(**args) 15 | 16 | def decode(self): 17 | event = {key: value for key, value in self.serializeditems()} 18 | return event 19 | -------------------------------------------------------------------------------- /tests/events/test_events.py: -------------------------------------------------------------------------------- 1 | from kant.events import DecimalField, Event 2 | from kant.exceptions import EventDoesNotExist 3 | 4 | import pytest 5 | 6 | 7 | def test_event_should_encode_obj(): 8 | # arrange 9 | class MyEvent(Event): 10 | amount = DecimalField() 11 | 12 | serialized = {"$type": "MyEvent", "amount": 20} 13 | # act 14 | my_event_model = Event.make(serialized) 15 | # assert 16 | assert isinstance(my_event_model, MyEvent), type(my_event_model) 17 | assert my_event_model.amount == 20 18 | 19 | 20 | def test_event_should_raise_when_encode_invalid_obj(): 21 | # arrange 22 | serialized = {"$type": "UtopicEvent", "$version": 0, "amount": 20} 23 | # act and assert 24 | with pytest.raises(EventDoesNotExist): 25 | my_event_model = Event.make(serialized) 26 | -------------------------------------------------------------------------------- /kant/eventstore/exceptions.py: -------------------------------------------------------------------------------- 1 | class VersionError(Exception): 2 | pass 3 | 4 | 5 | class StreamExists(VersionError): 6 | 7 | def __init__(self, event, *args, **kwargs): 8 | message = "The EventStream is not empty. The Event '{}' cannot be added.".format( 9 | event.__class__.__name__ 10 | ) 11 | super().__init__(message, *args, **kwargs) 12 | 13 | 14 | class DependencyDoesNotExist(VersionError): 15 | 16 | def __init__(self, event, dependencies, *args, **kwargs): 17 | message = "The Event '{}' expected {}.".format( 18 | event.__class__.__name__, dependencies 19 | ) 20 | super().__init__(message, *args, **kwargs) 21 | 22 | 23 | class DatabaseError(Exception): 24 | pass 25 | 26 | 27 | class IntegrityError(DatabaseError): 28 | pass 29 | 30 | 31 | class StreamDoesNotExist(DatabaseError): 32 | pass 33 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | Installation 4 | ============= 5 | 6 | Kant supports Python versions 3.5 and up and is installable via pip or from source. 7 | 8 | Via pip 9 | ------- 10 | 11 | To install kant, simply run the following command in a terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ pip install -U kant 16 | 17 | If you don’t have pip_ installed, check out this `this guide`__. 18 | 19 | Via Source Code 20 | --------------- 21 | 22 | To install the latest development version of dramatiq from source, clone the repo from GitHub_ 23 | 24 | .. code-block:: console 25 | 26 | $ get clone https://github.com/patrickporto/kant 27 | 28 | then install it to your local site-packages by running 29 | 30 | .. code-block:: console 31 | 32 | $ python setup.py install 33 | 34 | in the cloned directory. 35 | 36 | .. _pip: https://pip.pypa.io/en/stable/ 37 | .. _pip-guide: http://docs.python-guide.org/en/latest/starting/installation/ 38 | .. _GitHub: https://github.com/patrickporto/kant 39 | __ pip-guide_ 40 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Kant is open-source and very open to contributions 5 | 6 | Submitting patches (bugfix, features, ...) 7 | ------------------------------------------ 8 | 9 | If you want to contribute some code: 10 | 11 | 1. fork the `official kant repository`. 12 | 2. create a branch with an explicit name (like ``new-feature`` or ``issue-XXX``) 13 | 3. do your work in it 14 | 4. add you change to the changelog 15 | 5. submit your pull-request 16 | 17 | There are some rules to follow: 18 | 19 | - your contribution should be documented (if needed) 20 | - your contribution should be tested and the test suite should pass successfully 21 | - your code should be mostly Code Style compatible. 22 | 23 | You need to install some dependencies to develop on kant: 24 | 25 | .. code-block:: console 26 | 27 | $ pipenv --three install --dev 28 | 29 | You can preview the documentation: 30 | 31 | .. code-block:: console 32 | 33 | $ make docs-live 34 | 35 | .. _official kant repository: https://github.com/patrickporto/kant 36 | .. _official bugtracker: https://github.com/patrickporto/kant/issues 37 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Patrick da Silveira Porto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /kant/projections/sa.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import and_, literal_column 2 | 3 | from .exceptions import ProjectionDoesNotExist, ProjectionError 4 | 5 | 6 | class SQLAlchemyProjectionAdapter: 7 | 8 | def __init__(self, saconnection, router): 9 | self.saconnection = saconnection 10 | self.router = router 11 | 12 | async def handle_create(self, keyspace, steam, eventstream): 13 | projection = self.router.get_projection(keyspace, eventstream) 14 | stmt = self.router.get_model(keyspace).insert().values(**projection.decode()) 15 | await self.saconnection.execute(stmt) 16 | 17 | async def handle_update(self, keyspace, steam, eventstream): 18 | projection = self.router.get_projection(keyspace, eventstream) 19 | if not projection.primary_keys(): 20 | msg = "'{}' not have primary keys".format(projection.__class__.__name__) 21 | raise ProjectionError(msg) 22 | stmt = self.router.get_model(keyspace).update().values( 23 | **projection.decode() 24 | ).where( 25 | and_( 26 | literal_column(field) == value 27 | for field, value in projection.primary_keys().items() 28 | ) 29 | ) 30 | await self.saconnection.execute(stmt) 31 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = isort,black,mypy,py36,pypy3,docs 3 | 4 | [testenv] 5 | setenv = 6 | VIRTUALENV_NO_DOWNLOAD=1 7 | extras = tests 8 | commands = python setup.py test {posargs} 9 | 10 | 11 | [testenv:py36] 12 | install_command = pip install --no-compile {opts} {packages} 13 | setenv = 14 | PYTHONWARNINGS=d 15 | extras = tests 16 | commands = python setup.py test 17 | 18 | [testenv:flake8] 19 | basepython = python3.6 20 | extras = tests 21 | deps = 22 | flake8 23 | flake8-isort 24 | commands = flake8 kant tests setup.py docs/conf.py 25 | 26 | [testenv:isort] 27 | basepython = python3.6 28 | extras = tests 29 | deps = 30 | isort 31 | commands = 32 | isort --recursive setup.py kant tests 33 | 34 | [testenv:black] 35 | basepython = python3.6 36 | extras = tests 37 | deps = 38 | black 39 | commands = 40 | black setup.py kant tests 41 | 42 | [testenv:mypy] 43 | basepython = python3.6 44 | extras = tests 45 | deps = 46 | mypy 47 | commands = 48 | mypy kant --ignore-missing-imports 49 | 50 | [testenv:docs] 51 | basepython = python3.6 52 | setenv = 53 | PYTHONHASHSEED = 0 54 | extras = docs 55 | commands = 56 | sphinx-build -W -b html -d {envtmpdir}/doctrees docs docs/_build/html 57 | sphinx-build -W -b doctest -d {envtmpdir}/doctrees docs docs/_build/html 58 | python -m doctest README.md 59 | -------------------------------------------------------------------------------- /tests/eventstore/backends/test_aiopg.py: -------------------------------------------------------------------------------- 1 | from kant.eventstore.backends.aiopg import EventStoreConnection 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_create_table_should_create_table_if_not_exists(dbsession): 8 | # arrange 9 | settings = {"pool": dbsession} 10 | connection = await EventStoreConnection.create(settings) 11 | # act 12 | await connection.create_keyspace("event_store") 13 | # assert 14 | async with dbsession.cursor() as cursor: 15 | await cursor.execute( 16 | """ 17 | SELECT EXISTS ( 18 | SELECT 1 19 | FROM information_schema.tables 20 | WHERE table_name = 'event_store' 21 | ); 22 | """ 23 | ) 24 | (exists,) = await cursor.fetchone() 25 | assert exists 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_drop_table_should_drop_table_if_exists(dbsession): 30 | # arrange 31 | settings = {"pool": dbsession} 32 | connection = await EventStoreConnection.create(settings) 33 | await connection.create_keyspace("event_store") 34 | # act 35 | await connection.drop_keyspace("event_store") 36 | # assert 37 | async with dbsession.cursor() as cursor: 38 | await cursor.execute( 39 | """ 40 | SELECT EXISTS ( 41 | SELECT 1 42 | FROM information_schema.tables 43 | WHERE table_name = 'event_store' 44 | ); 45 | """ 46 | ) 47 | (exists,) = await cursor.fetchone() 48 | assert not exists 49 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | 3 | import aiopg 4 | from aiopg.sa import create_engine 5 | from async_generator import async_generator, yield_ 6 | from kant.eventstore.connection import connect 7 | 8 | import pytest 9 | 10 | 11 | @pytest.fixture 12 | @async_generator 13 | async def dbsession(): 14 | conn = await aiopg.connect( 15 | user=environ.get("DATABASE_USER"), 16 | password=environ.get("DATABASE_PASSWORD"), 17 | database=environ.get("DATABASE_DATABASE"), 18 | host=environ.get("DATABASE_HOST", "localhost"), 19 | port=environ.get("DATABASE_PORT", 5432), 20 | ) 21 | await yield_(conn) 22 | conn.close() 23 | 24 | 25 | @pytest.fixture 26 | @async_generator 27 | async def saconnection(): 28 | engine = await create_engine( 29 | user=environ.get("DATABASE_USER"), 30 | password=environ.get("DATABASE_PASSWORD"), 31 | database=environ.get("DATABASE_DATABASE"), 32 | host=environ.get("DATABASE_HOST", "localhost"), 33 | port=environ.get("DATABASE_PORT", 5432), 34 | ) 35 | async with engine.acquire() as conn: 36 | async with conn.begin() as transaction: 37 | await yield_(conn) 38 | await transaction.rollback() 39 | 40 | 41 | @pytest.fixture 42 | @async_generator 43 | async def eventsourcing(dbsession): 44 | eventstore = await connect(pool=dbsession) 45 | await eventstore.create_keyspace("event_store") 46 | await yield_(eventstore) 47 | await eventstore.drop_keyspace("event_store") 48 | await eventstore.close() 49 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '3.6' 4 | - '3.6-dev' 5 | - '3.7-dev' 6 | - 'pypy3.5' 7 | services: 8 | - postgresql 9 | addons: 10 | postgresql: "9.4" 11 | env: 12 | - DATABASE_URL=postgresql://postgresql@localhost/test 13 | before_script: 14 | - psql -c 'create database test;' -U postgres 15 | install: 16 | - pip install codecov 17 | script: 18 | - python setup.py test 19 | after_success: 20 | - codecov 21 | deploy: 22 | provider: pypi 23 | user: "patrickporto" 24 | password: 25 | secure: RLz8ezfOTkT+ryTFY8IC2NyQLmfU7rQIq5cE4h7QLl+Kf2Wk2knNRdnSpN/HVXww2wfMw/JJxLN2ncI1rv1ZVsBx0r90580H3iNPoCJIVvUBK1OCLSRpNnYLhtCa7n1PTXwY7YWJXQ22CjpXgjVKOWCRfOtTIxpedLhIcNqeFm2GOhsea06PDlP1LWyr+at+n8vlXjH/49nH5ENRIRQ1F1xqWkmbFUPvJqbEvcC5ehU3Tlh5eNfIztO2VUDP3xJ3ZzqhqPEO3LfXtzOtR7khA1z1c76uBNyHYhWUSRdQBrbzhu183EhuG302Zrd2oZD0tqHjEir3SeQ5XUgeildRim205WSeTF+ugV+HuPtJk3Wb2c0jT3EaXuFxcZJKMGYNlXtIXjq5GHTmezhCQ8PP0oKwlAQrXT+w1lbvOl+3ANcaxxyy5edtgh2p5KGnn+ndmc1jdx/jjqv/IlDP7HqIjcsH9RXkNcxa6TPfDp8soODXpBujsuo11vAM7Zo277dygbVyYisEPfSMGeYKYlqk5kIZgiCggm+EE9nGRSmQjSTA7sdGeSVSQ4wjLT8kCVb4v4HXY0oZBfQkr7PcS1EbTEIbNAyVnUjUHnukn+bGnfB/7vU8zNPm2QFKnQLBB+8/uS7kKy7i2ACSXB7u1ZrXZnbST4sSzv2ZBgr4e1F5ecQ= 26 | on: 27 | branch: master 28 | tags: true 29 | notifications: 30 | webhooks: 31 | urls: 32 | - https://webhooks.gitter.im/e/fdadf77275b92279882f 33 | on_success: change # options: [always|never|change] default: always 34 | on_failure: always # options: [always|never|change] default: always 35 | on_start: never # options: [always|never|change] default: always 36 | -------------------------------------------------------------------------------- /kant/events/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta 2 | 3 | from kant.datamapper.base import FieldMapping, ModelMeta 4 | from kant.datamapper.fields import * # NOQA 5 | from kant.datamapper.models import * # NOQA 6 | 7 | from .exceptions import EventDoesNotExist, EventError 8 | 9 | 10 | class Event(FieldMapping, metaclass=ModelMeta): 11 | EVENT_JSON_COLUMN = "$type" 12 | version = IntegerField(default=0, json_column="$version") 13 | __empty_stream__ = False 14 | __dependencies__ = [] 15 | 16 | @classmethod 17 | def make(self, obj): 18 | if self.EVENT_JSON_COLUMN not in obj: 19 | msg = "'{}' is not defined in {}".format(self.EVENT_JSON_COLUMN, obj) 20 | raise EventError(msg) 21 | event_name = obj[self.EVENT_JSON_COLUMN] 22 | events = [ 23 | Event for Event in self.__subclasses__() if Event.__name__ == event_name 24 | ] 25 | try: 26 | Event = events[0] 27 | except IndexError: 28 | raise EventDoesNotExist() 29 | del obj[self.EVENT_JSON_COLUMN] 30 | json_columns = {} 31 | for name, field in Event.concrete_fields.items(): 32 | json_column = field.json_column or name 33 | json_columns[json_column] = name 34 | args = {json_columns[name]: value for name, value in obj.items()} 35 | return Event(**args) 36 | 37 | def decode(self): 38 | event = {key: value for key, value in self.serializeditems()} 39 | event.update({self.EVENT_JSON_COLUMN: self.__class__.__name__}) 40 | return event 41 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | 4 | [tool:pytest] 5 | addopts = --verbose --doctest-modules --pep8 --cov-config .coveragerc --cov=kant ./tests ./kant 6 | pep8maxlinelength = 160 7 | 8 | [build_sphinx] 9 | source-dir = docs 10 | 11 | [metadata] 12 | name = Kant 13 | version = attr: kant.__version__ 14 | home-page = http://github.com/patrickporto/kant 15 | author = Patrick Porto 16 | author_email = patrick.s.porto@gmail.com 17 | description = A CQRS and Event Sourcing framework for Python 18 | long-description = file: README 19 | long-description-content_type = text/markdown 20 | description-file = README.md 21 | keywords = eventsourcing, cqrs, eventstore 22 | license = MIT 23 | classifiers = 24 | Development Status :: 3 - Alpha 25 | Intended Audience :: Developers 26 | Topic :: Software Development :: Libraries :: Python Modules 27 | License :: OSI Approved :: MIT License 28 | Programming Language :: Python :: 3.5 29 | Programming Language :: Python :: 3.6 30 | Programming Language :: Python :: Implementation :: CPython 31 | 32 | project_urls = 33 | Bug Tracker=https://github.com/patrickporto/kant/issues 34 | Documentation=https://kant.readthedocs.io/en/latest/ 35 | Source Code=https://github.com/patrickporto/kant 36 | 37 | [options] 38 | zip_safe = false 39 | include_package_data = false 40 | packages = find: 41 | install-requires = 42 | python-dateutil 43 | inflection 44 | cuid.py 45 | async_generator 46 | asyncio_extras 47 | aiopg 48 | sqlalchemy 49 | 50 | setup_requires = 51 | pytest-runner 52 | 53 | tests_require = 54 | pytest 55 | pytest-pycodestyle 56 | pytest-cov 57 | pytest-asyncio 58 | 59 | python_requires = ~= 3.5 60 | -------------------------------------------------------------------------------- /kant/projections/base.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | from inflection import underscore 4 | from kant.datamapper.base import FieldMapping, ModelMeta 5 | from kant.datamapper.fields import * # NOQA 6 | from kant.eventstore.stream import EventStream 7 | 8 | 9 | class ProjectionManager: 10 | 11 | def __init__(self): 12 | self._adapters = set() 13 | 14 | def bind(self, adapter): 15 | self._adapters.add(adapter) 16 | 17 | async def notify_create(self, *args, **kwargs): 18 | for adapter in self._adapters: 19 | await adapter.handle_create(*args, **kwargs) 20 | 21 | async def notify_update(self, *args, **kwargs): 22 | for adapter in self._adapters: 23 | await adapter.handle_update(*args, **kwargs) 24 | 25 | 26 | class Projection(FieldMapping, metaclass=ModelMeta): 27 | 28 | def fetch_events(self, eventstream): 29 | for event in eventstream: 30 | self.when(event) 31 | 32 | def when(self, event): 33 | event_name = underscore(event.__class__.__name__) 34 | method_name = "when_{0}".format(event_name) 35 | try: 36 | method = getattr(self, method_name) 37 | method(event) 38 | except AttributeError: 39 | pass 40 | 41 | 42 | class ProjectionRouter: 43 | 44 | def __init__(self): 45 | self._projections = {} 46 | self._models = {} 47 | 48 | def add(self, keyspace, model, projection): 49 | self._models[keyspace] = model 50 | self._projections[keyspace] = projection 51 | 52 | def get_projection(self, keyspace, eventstream): 53 | if keyspace not in self._projections: 54 | raise ProjectionDoesNotExist(keyspace) 55 | Projection = self._projections[keyspace] 56 | projection = Projection() 57 | projection.fetch_events(eventstream) 58 | return projection 59 | 60 | def get_model(self, keyspace): 61 | if keyspace not in self._models: 62 | raise ProjectionDoesNotExist(keyspace) 63 | return self._models[keyspace] 64 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Kant: Event Sourcing and CQRS 2 | ================================ 3 | 4 | .. image:: https://badges.frapsoft.com/os/mit/mit.png?v=103 5 | :target: https://opensource.org/licenses/mit-license.php 6 | 7 | .. image:: https://travis-ci.org/patrickporto/kant.svg?branch=master 8 | :target: https://travis-ci.org/patrickporto/kant 9 | 10 | .. image:: https://codecov.io/github/patrickporto/kant/coverage.svg?branch=master 11 | :target: https://codecov.io/github/patrickporto/kant?branch=master 12 | 13 | .. image:: https://img.shields.io/pypi/v/kant.svg 14 | :target: https://pypi.python.org/pypi/kant 15 | 16 | .. image:: https://img.shields.io/pypi/pyversions/kant.svg 17 | :target: https://pypi.python.org/pypi/kant 18 | 19 | .. image:: https://img.shields.io/pypi/implementation/kant.svg 20 | :target: https://pypi.python.org/pypi/kant 21 | 22 | **Kant** is a framework for Event Sourcing and CQRS for Python with a focus on simplicity a performance. 23 | 24 | Here’s what it looks like: 25 | 26 | .. code-block:: python3 27 | 28 | from kant.eventstore import connect 29 | 30 | await connect(user='user', password='user', database='database') 31 | 32 | # create event store for bank_account 33 | conn.create_keyspace('bank_account') 34 | 35 | # create events 36 | bank_account_created = BankAccountCreated( 37 | id=123, 38 | owner='John Doe', 39 | ) 40 | deposit_performed = DepositPerformed( 41 | amount=20, 42 | ) 43 | 44 | bank_account = BankAccount() 45 | bank_account.dispatch([bank_account_created, deposit_performed]) 46 | bank_account.save() 47 | 48 | stored_bank_account = BankAccount.objects.get(123) 49 | 50 | 51 | **Kant** is licensed_ under the MIT and it officially supports Python 3.5 or later. 52 | 53 | Get It Now 54 | ---------- 55 | 56 | .. code-block:: console 57 | 58 | $ pip install kant 59 | 60 | 61 | User Guide 62 | ---------- 63 | 64 | This part of the documentation is focused primarily on teaching you how to use Kant. 65 | 66 | .. toctree:: 67 | :maxdepth: 2 68 | 69 | installation 70 | events 71 | commands 72 | projections 73 | eventstore 74 | testing 75 | 76 | API Reference 77 | ------------- 78 | 79 | This part of the documentation is focused on detailing the various bits and pieces of the Kant developer interface. 80 | 81 | .. toctree:: 82 | :maxdepth: 2 83 | 84 | reference 85 | 86 | .. _licensed: https://opensource.org/licenses/mit-license.php 87 | -------------------------------------------------------------------------------- /tests/eventstore/test_stream.py: -------------------------------------------------------------------------------- 1 | from kant import events 2 | from kant.eventstore.exceptions import DependencyDoesNotExist, StreamExists 3 | from kant.eventstore.stream import EventStream 4 | 5 | import pytest 6 | 7 | 8 | class BankAccountCreated(events.Event): 9 | __empty_stream__ = True 10 | 11 | id = events.CUIDField(primary_key=True) 12 | owner = events.CharField() 13 | 14 | 15 | class DepositPerformed(events.Event): 16 | amount = events.DecimalField() 17 | 18 | 19 | class WithdrawalPerformed(events.Event): 20 | amount = events.DecimalField() 21 | 22 | 23 | class OwnerChanged(events.Event): 24 | __dependencies__ = ["BankAccountCreated"] 25 | 26 | new_owner = events.CharField() 27 | 28 | 29 | @pytest.mark.asyncio 30 | async def test_eventstream_should_append_new_event(): 31 | # arrange 32 | bank_account_created = BankAccountCreated( 33 | id="052c21b6-aab9-4311-b954-518cd04f704c", owner="John Doe" 34 | ) 35 | # act 36 | event_stream = EventStream() 37 | event_stream.add(bank_account_created) 38 | # assert 39 | assert len(event_stream) == 1 40 | assert event_stream.current_version == 0 41 | assert list(event_stream)[0] == bank_account_created 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_eventstream_should_append_new_event_only_once(): 46 | # arrange 47 | bank_account_created = BankAccountCreated( 48 | id="052c21b6-aab9-4311-b954-518cd04f704c", owner="John Doe" 49 | ) 50 | # act 51 | event_stream = EventStream() 52 | event_stream.add(bank_account_created) 53 | event_stream.add(bank_account_created) 54 | # assert 55 | assert len(event_stream) == 1 56 | assert event_stream.current_version == 0 57 | assert list(event_stream)[0] == bank_account_created 58 | 59 | 60 | @pytest.mark.asyncio 61 | async def test_eventstream_should_raise_stream_exists_when_stream_exists(): 62 | # arrange 63 | bank_account_created_1 = BankAccountCreated( 64 | id="052c21b6-aab9-4311-b954-518cd04f704c", owner="John Doe" 65 | ) 66 | bank_account_created_2 = BankAccountCreated( 67 | id="052c21b6-aab9-4311-b954-518cd04f704c", owner="John Doe" 68 | ) 69 | # act 70 | event_stream = EventStream() 71 | event_stream.add(bank_account_created_1) 72 | # assert 73 | with pytest.raises(StreamExists): 74 | event_stream.add(bank_account_created_2) 75 | 76 | 77 | @pytest.mark.asyncio 78 | async def test_eventstream_should_raise_version_error_when_dependency_not_found(): 79 | # arrange 80 | owner_changed = OwnerChanged(new_owner="Jane Doe") 81 | # act and assert 82 | with pytest.raises(DependencyDoesNotExist) as e: 83 | event_stream = EventStream() 84 | event_stream.add(owner_changed) 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kant Framework 2 | 3 | **WARNING: This repository is unmaintained** 4 | 5 | > A CQRS and Event Sourcing framework, safe for humans. 6 | 7 | [![Build Status](https://travis-ci.org/patrickporto/kant.svg?branch=master)](https://travis-ci.org/patrickporto/kant) 8 | [![codecov.io](https://codecov.io/github/patrickporto/kant/coverage.svg?branch=master)](https://codecov.io/github/patrickporto/kant?branch=master) 9 | [![PyPI Package latest release](https://img.shields.io/pypi/v/kant.svg)](https://pypi.python.org/pypi/kant) 10 | [![Supported versions](https://img.shields.io/pypi/pyversions/kant.svg)](https://pypi.python.org/pypi/kant) 11 | [![Supported implementations](https://img.shields.io/pypi/implementation/kant.svg)](https://pypi.python.org/pypi/kant) 12 | [![Join the chat at https://gitter.im/kant-es/Lobby](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/kant-es/Lobby) 13 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) 14 | 15 | 16 | ## Feature Support 17 | 18 | * Event Store 19 | * Optimistic concurrency control 20 | * JSON serialization 21 | * SQLAlchemy Projections 22 | * Snapshots **[IN PROGRESS]** 23 | 24 | Kant officially supports Python 3.5-3.6. 25 | 26 | ## Getting started 27 | 28 | Create declarative events 29 | 30 | ```python 31 | from kant import events 32 | 33 | class BankAccountCreated(events.Event): 34 | id = events.CUIDField(primary_key=True) 35 | owner = events.CharField() 36 | 37 | class DepositPerformed(events.Event): 38 | amount = events.DecimalField() 39 | ``` 40 | 41 | Create aggregate to apply events 42 | 43 | ```python 44 | from kant import aggregates 45 | 46 | class BankAccount(aggregates.Aggregate): 47 | id = aggregates.CUIDField() 48 | owner = aggregates.CharField() 49 | balance = aggregates.DecimalField() 50 | 51 | def apply_bank_account_created(self, event): 52 | self.id = event.id 53 | self.owner = event.owner 54 | self.balance = 0 55 | 56 | def apply_deposit_performed(self, event): 57 | self.balance += event.amount 58 | ``` 59 | 60 | Now, save the events 61 | 62 | ```python 63 | from kant.eventstore import connect 64 | 65 | await connect(user='user', password='user', database='database') 66 | 67 | # create event store for bank_account 68 | conn.create_keyspace('bank_account') 69 | 70 | # create events 71 | bank_account_created = BankAccountCreated( 72 | id=123, 73 | owner='John Doe', 74 | ) 75 | deposit_performed = DepositPerformed( 76 | amount=20, 77 | ) 78 | 79 | bank_account = BankAccount() 80 | bank_account.dispatch([bank_account_created, deposit_performed]) 81 | bank_account.save() 82 | 83 | stored_bank_account = BankAccount.objects.get(123) 84 | ``` 85 | 86 | ## Installing 87 | To install Kant, simply use [pipenv](pipenv.org) (or pip) 88 | 89 | ```bash 90 | $ pipenv install kant 91 | ``` 92 | 93 | 94 | 95 | ## Contributing 96 | 97 | Please, read the contribute guide [CONTRIBUTING](CONTRIBUTING.md). 98 | -------------------------------------------------------------------------------- /kant/eventstore/stream.py: -------------------------------------------------------------------------------- 1 | import json 2 | from operator import attrgetter 3 | 4 | from kant.events import Event 5 | 6 | from .exceptions import DependencyDoesNotExist, StreamExists 7 | 8 | 9 | class EventStream: 10 | 11 | def __init__(self, events=None): 12 | self.initial_version = -1 13 | self.current_version = -1 14 | self._events = set() 15 | if events is not None: 16 | for event in list(events): 17 | self.initial_version += 1 18 | self.add(event) 19 | 20 | def __eq__(self, event_stream): 21 | return self.current_version == event_stream.current_version 22 | 23 | def __lt__(self, event_stream): 24 | return self.current_version < event_stream.current_version 25 | 26 | def __le__(self, event_stream): 27 | return self.current_version <= event_stream.current_version 28 | 29 | def __ne__(self, event_stream): 30 | return self.current_version != event_stream.current_version 31 | 32 | def __gt__(self, event_stream): 33 | return self.current_version > event_stream.current_version 34 | 35 | def __ge__(self, event_stream): 36 | return self.current_version >= event_stream.current_version 37 | 38 | def __len__(self): 39 | return len(self._events) 40 | 41 | def __add__(self, event_stream): 42 | for event in event_stream: 43 | self.add(event) 44 | return self 45 | 46 | def __iter__(self): 47 | return iter(sorted(self._events, key=attrgetter("version"))) 48 | 49 | def __repr__(self): 50 | return str(list(self)) 51 | 52 | def _valid_empty_stream(self, event): 53 | if event.__empty_stream__ and len(self._events) > 0: 54 | raise StreamExists(event) 55 | 56 | def _valid_dependencies(self, event): 57 | event_names = [event.__class__.__name__ for event in self._events] 58 | not_found = [ 59 | event for event in event.__dependencies__ if event not in event_names 60 | ] 61 | if len(not_found) > 0: 62 | raise DependencyDoesNotExist(event, not_found) 63 | 64 | def _conflict_resolution(self, event): 65 | self._valid_empty_stream(event) 66 | self._valid_dependencies(event) 67 | return event 68 | 69 | def exists(self): 70 | return self.initial_version != -1 71 | 72 | def clear(self): 73 | self._events = set() 74 | 75 | def add(self, event): 76 | if event not in self._events: 77 | event = self._conflict_resolution(event) 78 | self.current_version += 1 79 | event.version = self.current_version 80 | self._events.add(event) 81 | 82 | def decode(self): 83 | return [event.decode() for event in self._events] 84 | 85 | def json(self): 86 | return json.dumps(self.decode(), sort_keys=True) 87 | 88 | @classmethod 89 | def make(self, obj): 90 | return EventStream([Event.make(event) for event in obj]) 91 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [3.0.0] 8 | ### Added 9 | - Add CHANGELOG 10 | - Initial configuration for sphinx 11 | 12 | ### Changed 13 | - Improves the contribute guide 14 | 15 | ## [2.1.0] - 2018-02-16 16 | ### Added 17 | - Add close on EventStoreConnection 18 | 19 | ## [2.0.2] - 2018-02-09 20 | ### Fixed 21 | - Manager default connection 22 | 23 | ## [2.0.1] - 2018-02-09 24 | ### Fixed 25 | - Move get_pk to Aggregate 26 | 27 | ## [2.0.0] - 2018-02-08 28 | ### Added 29 | - Add EventStoreConnection 30 | - Add new constructor from_stream on Aggregate 31 | - Add list support on dispatch 32 | - SQLAlchemy Projections 33 | - Add Manager for easier the connection 34 | - Add refresh_from_db on Aggregate 35 | - Add only option on json method 36 | 37 | ### Changed 38 | - Rename EventModel to Event 39 | 40 | ### Fixed 41 | - Side effect on EventStream 42 | 43 | ### Removed 44 | - Remove EventModelEncoder 45 | 46 | ## [1.1.2] - 2018-01-29 47 | ### Fixed 48 | - The library requires async_generator 49 | 50 | ## [1.1.1] - 2018-01-29 51 | ### Fixed 52 | - Async Generator on Python 3.5 53 | 54 | ## [1.1.0] - 2018-01-29 55 | ### Changed 56 | - The database schema was using a connection instead of cursor 57 | 58 | ## [1.0.4] - 2018-01-27 59 | ### Fixed 60 | - Version number 61 | 62 | ## [1.0.3] - 2018-01-27 63 | ### Fixed 64 | - Some packages were not included in the setup 65 | 66 | ## [1.0.2] - 2018-01-27 67 | ### Fixed 68 | - Some packages were not included in the setup 69 | 70 | ## [1.0.1] - 2018-01-27 71 | ### Fixed 72 | - Version number 73 | 74 | ## [1.0.0] - 2018-01-26 75 | ### Added 76 | - Add minimal documentation 77 | - Add consistency mechanism for events 78 | 79 | ### Changed 80 | - Rename TrackedEntity to Aggregate 81 | - Rename from_dict to make 82 | - Improve error handling 83 | - Rename EventModelMeta to ModelMeta 84 | - Rename EventFieldMapping to FieldMapping 85 | 86 | ### Removed 87 | - SQLAlchemy as the main dependency 88 | - `event_name` in favour of __class__.__name__ 89 | 90 | ## [0.3.0] - 2018-01-16 91 | ### Added 92 | - Add EventStream 93 | - Add Pessimistic Concurrency 94 | 95 | ## [0.2.1] - 2018-01-16 96 | ### Fixed 97 | - The get of EventStore was not working 98 | 99 | ## [0.2.0] - 2018-01-15 100 | ### Added 101 | - Add CUIDField 102 | 103 | ### Removed 104 | - Remove UUIDField 105 | 106 | ## [0.1.4] - 2018-01-12 107 | ### Added 108 | - Add the library license 109 | 110 | ## [0.1.3] - 2018-01-11 111 | ### Fixed 112 | - The packages were not included in the setup 113 | 114 | ## [0.1.2] - 2018-01-11 115 | ### Added 116 | - Setup the new version release by master 117 | 118 | ### Fixed 119 | - Some codes were still with the literal string interpolation 120 | 121 | ## [0.1.1] - 2018-01-11 122 | ### Changed 123 | - Replace literal string interpolation to oldest string format 124 | 125 | ## [0.0.1] - 2018-01-11 126 | ### Added 127 | - Add Data Mapper for JSON 128 | - Add declarative events for serialization 129 | - Add JSONEncoder for json library 130 | - Add EventStore on SQLAlchemy 131 | -------------------------------------------------------------------------------- /kant/datamapper/base.py: -------------------------------------------------------------------------------- 1 | import json 2 | from abc import ABCMeta 3 | from collections import MutableMapping 4 | from typing import Dict 5 | 6 | from .exceptions import FieldError 7 | from .fields import Field 8 | 9 | 10 | class ModelMeta(ABCMeta): 11 | 12 | def __new__(mcs, class_name, bases, attrs): 13 | concrete_fields = {} 14 | new_attrs = {} 15 | for name, value in attrs.items(): 16 | if isinstance(value, Field): 17 | concrete_fields[name] = value 18 | else: 19 | new_attrs[name] = value 20 | 21 | cls = type.__new__(mcs, class_name, bases, new_attrs) 22 | cls.concrete_fields = cls.concrete_fields.copy() 23 | cls.concrete_fields.update(concrete_fields) 24 | return cls 25 | 26 | 27 | class FieldMapping(MutableMapping): 28 | concrete_fields: Dict[str, Field] = {} 29 | 30 | def __init__(self, *args, **kwargs): 31 | self._values = {} 32 | initial = {} 33 | for name, value in self.concrete_fields.items(): 34 | if value.default is not None: 35 | initial[name] = value.default_value() 36 | initial.update(dict(*args, **kwargs)) 37 | if args or kwargs: # avoid creating dict for most common case 38 | for name, value in initial.items(): 39 | self[name] = value 40 | 41 | def __getitem__(self, key): 42 | return self._values[key] 43 | 44 | def __setitem__(self, key, value): 45 | if key in self.concrete_fields: 46 | try: 47 | self._values[key] = self.concrete_fields[key].parse(value) 48 | except TypeError as e: 49 | msg = ( 50 | "The value '{value}' is invalid. " "The field '{key}' {exception}" 51 | ).format( 52 | value=repr(value), field=key, exception=str(e) 53 | ) 54 | raise TypeError(msg) 55 | else: 56 | msg = "{class_name} does not support field: {field}".format( 57 | class_name=self.__class__.__name__, field=key 58 | ) 59 | raise KeyError(msg) 60 | 61 | def __delitem__(self, key): 62 | del self._values[key] 63 | 64 | def __getattr__(self, name): 65 | if name not in self.concrete_fields: 66 | return super().__getattribute__(name) 67 | return self._values[name] 68 | 69 | def __setattr__(self, name, value): 70 | if name not in self.concrete_fields: 71 | return super().__setattr__(name, value) 72 | try: 73 | if value is not None: 74 | self._values[name] = self.concrete_fields[name].parse(value) 75 | except KeyError: 76 | msg = "The field '{0}' is not defined in '{1}'.".format( 77 | name, self.__class__.__name__ 78 | ) 79 | raise FieldError(msg) 80 | 81 | def keys(self): 82 | return self._values.keys() 83 | 84 | def __repr__(self): 85 | return "<{0}: {1}>".format(self.__class__.__name__, self._values) 86 | 87 | def copy(self): 88 | return self.__class__(self) 89 | 90 | def __len__(self): 91 | return len(self._values) 92 | 93 | def __iter__(self): 94 | return iter(self._values) 95 | 96 | def __hash__(self): 97 | return id(self) 98 | 99 | def serializeditems(self): 100 | for name, value in self._values.items(): 101 | field_name = self.concrete_fields[name].json_column or name 102 | field_value = self.concrete_fields[name].encode(value) 103 | yield (field_name, field_value) 104 | 105 | def decode(self): 106 | return {key: value for key, value in self.serializeditems()} 107 | 108 | def json(self, only=None): 109 | data = self.decode() 110 | if only is not None: 111 | data = {key: value for key, value in data.items() if key in only} 112 | return json.dumps(data, sort_keys=True) 113 | 114 | def primary_keys(self): 115 | primary_keys = {} 116 | for name, value in self._values.items(): 117 | field = self.concrete_fields[name] 118 | field_name = field.json_column or name 119 | field_value = field.encode(value) 120 | if field.primary_key: 121 | primary_keys[field_name] = field_value 122 | return primary_keys 123 | -------------------------------------------------------------------------------- /kant/datamapper/fields.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta 2 | from datetime import datetime 3 | from decimal import Decimal 4 | 5 | from cuid import cuid 6 | from dateutil import parser as dateutil_parser 7 | 8 | 9 | class Field(metaclass=ABCMeta): 10 | 11 | def __init__( 12 | self, default=None, json_column=None, primary_key=False, *args, **kwargs 13 | ): 14 | self.primary_key = primary_key 15 | self.default = default 16 | self.json_column = json_column 17 | 18 | def encode(self, value): 19 | return value 20 | 21 | def parse(self, value): 22 | return value 23 | 24 | def default_value(self): 25 | if callable(self.default): 26 | return self.default() 27 | return self.default 28 | 29 | 30 | class CUIDField(Field): 31 | 32 | def __init__(self, *args, **kwargs): 33 | super().__init__(*args, **kwargs) 34 | if self.primary_key: 35 | self.default = cuid 36 | 37 | def encode(self, value): 38 | return str(value) 39 | 40 | def parse(self, value): 41 | return value 42 | 43 | 44 | class DecimalField(Field): 45 | 46 | def encode(self, value): 47 | return float(value) 48 | 49 | def parse(self, value): 50 | return Decimal(value) 51 | 52 | 53 | class IntegerField(Field): 54 | 55 | def encode(self, value): 56 | return int(value) 57 | 58 | def parse(self, value): 59 | return int(value) 60 | 61 | 62 | class DateTimeField(Field): 63 | 64 | def __init__(self, auto_now=False, *args, **kwargs): 65 | self.auto_now = auto_now 66 | super().__init__(*args, **kwargs) 67 | if self.auto_now: 68 | self.default = lambda: datetime.now() 69 | 70 | def encode(self, value): 71 | """ 72 | >>> field = DateTimeField() 73 | >>> create_at = datetime(2009, 5, 28, 16, 15) 74 | >>> field.encode(create_at) 75 | '2009-05-28T16:15:00' 76 | """ 77 | return value.isoformat() 78 | 79 | def parse(self, value): 80 | """ 81 | >>> field = DateTimeField() 82 | >>> field.parse('2009-05-28T16:15:00') 83 | datetime.datetime(2009, 5, 28, 16, 15) 84 | >>> create_at = datetime(2009, 5, 28, 16, 15) 85 | >>> field.parse(create_at) 86 | datetime.datetime(2009, 5, 28, 16, 15) 87 | """ 88 | if isinstance(value, str): 89 | return dateutil_parser.parse(value) 90 | if not isinstance(value, datetime): 91 | raise TypeError("expected string or datetime object") 92 | return value 93 | 94 | 95 | class CharField(Field): 96 | 97 | def encode(self, value): 98 | """ 99 | >>> field = CharField() 100 | >>> field.encode(123) 101 | '123' 102 | """ 103 | return str(value) 104 | 105 | def parse(self, value): 106 | """ 107 | >>> field = CharField() 108 | >>> field.parse(123) 109 | '123' 110 | """ 111 | return str(value) 112 | 113 | 114 | class BooleanField(Field): 115 | 116 | def encode(self, value): 117 | """ 118 | >>> field = BooleanField() 119 | >>> field.encode(True) 120 | 'true' 121 | >>> field.encode(False) 122 | 'false' 123 | """ 124 | if value: 125 | return "true" 126 | return "false" 127 | 128 | def parse(self, value): 129 | """ 130 | >>> field = BooleanField() 131 | >>> field.parse(True) 132 | True 133 | >>> field.parse(False) 134 | False 135 | >>> field.parse('true') 136 | True 137 | >>> field.parse('false') 138 | False 139 | """ 140 | if isinstance(value, str) and value.strip() == "false": 141 | return False 142 | return bool(value) 143 | 144 | 145 | class SchemaField(Field): 146 | 147 | def __init__(self, to, *args, **kwargs): 148 | self.to = to 149 | super().__init__(*args, **kwargs) 150 | 151 | def encode(self, value): 152 | """ 153 | >>> account = AccountSchemaModel(balance=25.5) 154 | >>> field = SchemaField(to=AccountSchemaModel) 155 | >>> field.encode(account) 156 | {'balance': 25.5} 157 | """ 158 | return value.decode() 159 | 160 | def parse(self, value): 161 | """ 162 | >>> account = AccountSchemaModel(balance=25.5) 163 | >>> field = SchemaField(to=AccountSchemaModel) 164 | >>> field.parse(account) 165 | 166 | >>> field.parse({"balance": 30 }) 167 | 168 | """ 169 | if isinstance(value, self.to): 170 | return value 171 | return self.to().make(value, cls=self.to) 172 | -------------------------------------------------------------------------------- /kant/aggregates/base.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | from async_generator import async_generator, yield_ 4 | from inflection import underscore 5 | from kant.datamapper.base import FieldMapping, ModelMeta 6 | from kant.datamapper.fields import * # NOQA 7 | from kant.eventstore import EventStream, get_connection 8 | 9 | from .exceptions import AggregateError 10 | 11 | 12 | class Manager: 13 | 14 | def __init__(self, model, keyspace, using=None): 15 | self._model = model 16 | self.using = using 17 | self.keyspace = keyspace 18 | 19 | @property 20 | def _conn(self): 21 | if self.using is None: 22 | return get_connection() 23 | return self.using 24 | 25 | async def save(self, aggregate_id, events, notify_save): 26 | async with self._conn.open(self.keyspace) as eventstore: 27 | await eventstore.append_to_stream(aggregate_id, events, notify_save) 28 | 29 | async def get(self, aggregate_id): 30 | async with self._conn.open(self.keyspace) as eventstore: 31 | stream = await eventstore.get_stream(aggregate_id) 32 | return self._model.from_stream(stream) 33 | 34 | @async_generator 35 | async def all(self): 36 | async with self._conn.open(self.keyspace) as eventstore: 37 | async for stream in eventstore.all_streams(): 38 | await yield_(self._model.from_stream(stream)) 39 | 40 | async def get_stream(self, aggregate_id): 41 | async with self._conn.open(self.keyspace) as eventstore: 42 | return await eventstore.get_stream(aggregate_id) 43 | 44 | 45 | class AggregateMeta(ModelMeta): 46 | 47 | def __new__(mcs, class_name, bases, attrs): 48 | cls = ModelMeta.__new__(mcs, class_name, bases, attrs) 49 | if "__keyspace__" in attrs.keys(): 50 | cls.objects = Manager(model=cls, keyspace=attrs["__keyspace__"]) 51 | return cls 52 | 53 | 54 | class Aggregate(FieldMapping, metaclass=AggregateMeta): 55 | 56 | def __init__(self): 57 | super().__init__() 58 | self._all_events = EventStream() 59 | self._events = EventStream() 60 | self._stored_events = EventStream() 61 | 62 | def all_events(self): 63 | return self._all_events 64 | 65 | def stored_events(self): 66 | return self._stored_events 67 | 68 | def get_events(self): 69 | return self._events 70 | 71 | def notify_save(self, new_version): 72 | self._events.clear() 73 | self._events.initial_version = new_version 74 | self._all_events.initial_version = new_version 75 | self._stored_events.initial_version = new_version 76 | 77 | def fetch_events(self, events: EventStream): 78 | self._stored_events = deepcopy(events) 79 | self._all_events = deepcopy(self._stored_events) 80 | self._events.initial_version = events.initial_version 81 | for event in events: 82 | self.dispatch(event, flush=False) 83 | 84 | def apply(self, event): 85 | event_name = underscore(event.__class__.__name__) 86 | method_name = "apply_{0}".format(event_name) 87 | try: 88 | method = getattr(self, method_name) 89 | method(event) 90 | except AggregateError: 91 | msg = "The command for '{}' is not defined".format(event.__class__.__name__) 92 | raise CommandError(msg) 93 | 94 | def dispatch(self, events, flush=True): 95 | if isinstance(events, list): 96 | received_events = list(events) 97 | else: 98 | received_events = [events] 99 | for event in received_events: 100 | self.apply(event) 101 | if flush: 102 | self._events.add(event) 103 | self._all_events.add(event) 104 | 105 | @property 106 | def current_version(self): 107 | return self._all_events.current_version 108 | 109 | @property 110 | def version(self): 111 | return self._all_events.initial_version 112 | 113 | @classmethod 114 | def from_stream(cls, stream): 115 | self = cls() 116 | self.fetch_events(stream) 117 | return self 118 | 119 | async def save(self): 120 | return await self.objects.save( 121 | self.get_pk(), self.get_events(), self.notify_save 122 | ) 123 | 124 | async def refresh_from_db(self): 125 | stream = await self.objects.get_stream(self.get_pk()) 126 | self.fetch_events(stream) 127 | return self 128 | 129 | def get_pk(self): 130 | primary_keys = list(self.primary_keys().values()) 131 | if not primary_keys: 132 | msg = "Nothing primary key defined for '{}'".format(self.__class__.__name__) 133 | raise AggregateError(msg) 134 | elif len(primary_keys) > 1: 135 | msg = "Many primary keys defined for '{}'".format(self.__class__.__name__) 136 | raise AggregateError(msg) 137 | return primary_keys[0] 138 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Kant documentation build configuration file, created by 5 | # sphinx-quickstart on Tue Jan 23 19:52:39 2018. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = ['sphinx.ext.autodoc', 35 | 'sphinx.ext.doctest', 36 | 'sphinx.ext.coverage', 37 | 'sphinx.ext.imgmath', 38 | 'sphinx.ext.viewcode', 39 | 'sphinx.ext.githubpages'] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ['_templates'] 43 | 44 | # The suffix(es) of source filenames. 45 | # You can specify multiple suffix as a list of string: 46 | # 47 | # source_suffix = ['.rst', '.md'] 48 | source_suffix = '.rst' 49 | 50 | # The master toctree document. 51 | master_doc = 'index' 52 | 53 | # General information about the project. 54 | project = "Kant" 55 | copyright = '2018, Patrick Porto' 56 | author = 'Patrick Porto' 57 | 58 | # The version info for the project you're documenting, acts as replacement for 59 | # |version| and |release|, also used in various other places throughout the 60 | # built documents. 61 | # 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # 66 | # This is also used if you do content translation via gettext catalogs. 67 | # Usually you set "language" from the command line for these cases. 68 | language = None 69 | 70 | # List of patterns, relative to source directory, that match files and 71 | # directories to ignore when looking for source files. 72 | # This patterns also effect to html_static_path and html_extra_path 73 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 74 | 75 | # The name of the Pygments (syntax highlighting) style to use. 76 | pygments_style = 'sphinx' 77 | 78 | # If true, `todo` and `todoList` produce output, else they produce nothing. 79 | todo_include_todos = False 80 | 81 | 82 | # -- Options for HTML output ---------------------------------------------- 83 | 84 | # The theme to use for HTML and HTML Help pages. See the documentation for 85 | # a list of builtin themes. 86 | # 87 | html_theme = 'alabaster' 88 | 89 | # Theme options are theme-specific and customize the look and feel of a theme 90 | # further. For a list of options available for each theme, see the 91 | # documentation. 92 | # 93 | # html_theme_options = {} 94 | 95 | # Add any paths that contain custom static files (such as style sheets) here, 96 | # relative to this directory. They are copied after the builtin static files, 97 | # so a file named "default.css" will overwrite the builtin "default.css". 98 | html_static_path = ['_static'] 99 | 100 | # Custom sidebar templates, must be a dictionary that maps document names 101 | # to template names. 102 | # 103 | # This is required for the alabaster theme 104 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 105 | html_sidebars = { 106 | '**': [ 107 | 'about.html', 108 | 'navigation.html', 109 | 'relations.html', # needs 'show_related': True theme option to display 110 | 'searchbox.html', 111 | ] 112 | } 113 | 114 | html_theme_options = { 115 | 'show_related': True, 116 | 'logo': 'logo.jpg', 117 | 'logo_name': True, 118 | 'description': 'A CQRS and Event Sourcing framework for Python', 119 | 'github_user': 'patrickporto', 120 | 'github_repo': 'kant', 121 | } 122 | 123 | # -- Options for HTMLHelp output ------------------------------------------ 124 | 125 | # Output file base name for HTML help builder. 126 | htmlhelp_basename = 'Kantdoc' 127 | 128 | 129 | # -- Options for LaTeX output --------------------------------------------- 130 | 131 | latex_elements = { 132 | # The paper size ('letterpaper' or 'a4paper'). 133 | # 134 | # 'papersize': 'letterpaper', 135 | 136 | # The font size ('10pt', '11pt' or '12pt'). 137 | # 138 | # 'pointsize': '10pt', 139 | 140 | # Additional stuff for the LaTeX preamble. 141 | # 142 | # 'preamble': '', 143 | 144 | # Latex figure (float) alignment 145 | # 146 | # 'figure_align': 'htbp', 147 | } 148 | 149 | # Grouping the document tree into LaTeX files. List of tuples 150 | # (source start file, target name, title, 151 | # author, documentclass [howto, manual, or own class]). 152 | latex_documents = [ 153 | (master_doc, 'Kant.tex', 'Kant Documentation', 154 | 'Patrick Porto', 'manual'), 155 | ] 156 | 157 | 158 | # -- Options for manual page output --------------------------------------- 159 | 160 | # One entry per manual page. List of tuples 161 | # (source start file, name, description, authors, manual section). 162 | man_pages = [ 163 | (master_doc, 'kant', 'Kant Documentation', 164 | [author], 1) 165 | ] 166 | 167 | 168 | # -- Options for Texinfo output ------------------------------------------- 169 | 170 | # Grouping the document tree into Texinfo files. List of tuples 171 | # (source start file, target name, title, author, 172 | # dir menu entry, description, category) 173 | texinfo_documents = [ 174 | (master_doc, 'Kant', 'Kant Documentation', 175 | author, 'Kant', 'One line description of project.', 176 | 'Miscellaneous'), 177 | ] 178 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /kant/eventstore/backends/aiopg.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | import aiopg 4 | from async_generator import async_generator, yield_ 5 | from asyncio_extras.contextmanager import async_contextmanager 6 | from kant.projections import ProjectionManager 7 | 8 | from ..exceptions import StreamDoesNotExist, VersionError 9 | from ..stream import EventStream 10 | 11 | 12 | class EventStoreConnection: 13 | 14 | def __init__(self): 15 | self.projections = ProjectionManager() 16 | 17 | @classmethod 18 | async def create(cls, settings): 19 | self = EventStoreConnection() 20 | self.settings = settings 21 | self.pool = settings.get("pool") 22 | if self.pool is None: 23 | self.pool = await aiopg.connect( 24 | user=settings.get("user"), 25 | password=settings.get("password"), 26 | database=settings.get("database"), 27 | host=settings.get("host"), 28 | ) 29 | return self 30 | 31 | async def close(self): 32 | await self.pool.close() 33 | 34 | async def create_keyspace(self, keyspace): 35 | stmt = """ 36 | CREATE TABLE IF NOT EXISTS {keyspace} ( 37 | id varchar(255), 38 | data jsonb NOT NULL, 39 | created_at timestamp NOT NULL, 40 | updated_at timestamp NOT NULL, 41 | version bigserial NOT NULL 42 | ) 43 | """.format( 44 | keyspace=keyspace 45 | ) 46 | async with self.pool.cursor() as cursor: 47 | await cursor.execute(stmt) 48 | 49 | async def drop_keyspace(self, keyspace): 50 | stmt = """ 51 | DROP TABLE {keyspace} 52 | """.format( 53 | keyspace=keyspace 54 | ) 55 | async with self.pool.cursor() as cursor: 56 | await cursor.execute(stmt) 57 | 58 | @async_contextmanager 59 | async def open(self, keyspace): 60 | async with self.pool.cursor() as cursor: 61 | await yield_(EventStore(cursor, keyspace, self.projections)) 62 | 63 | 64 | class EventStore: 65 | 66 | def __init__(self, cursor, keyspace, projections): 67 | self.cursor = cursor 68 | self.keyspace = keyspace 69 | self.projections = projections 70 | 71 | async def get_stream(self, stream: str, start: int = 0, backward: bool = False): 72 | stmt_select = """ 73 | SELECT {keyspace}.data 74 | FROM {keyspace} WHERE {keyspace}.id = %(id)s 75 | """.format( 76 | keyspace=self.keyspace 77 | ) 78 | if backward: 79 | stmt_select += " AND CAST(data ? '$version' AS INTEGER) <= %(version)s" 80 | else: 81 | stmt_select += " AND CAST(data ? '$version' AS INTEGER) >= %(version)s" 82 | await self.cursor.execute(stmt_select, {"id": str(stream), "version": start}) 83 | eventstore_stream = await self.cursor.fetchone() 84 | if not eventstore_stream: 85 | raise StreamDoesNotExist(stream) 86 | return EventStream.make(eventstore_stream[0]) 87 | 88 | @async_generator 89 | async def all_streams(self, start: int = 0, end: int = -1): 90 | stmt_select = """ 91 | SELECT {keyspace}.data 92 | FROM {keyspace} 93 | ORDER BY id 94 | """.format( 95 | keyspace=self.keyspace 96 | ) 97 | if start > 0: 98 | stmt_select += " OFFSET {}".format(start) 99 | if end > -1: 100 | stmt_select += " LIMIT {}".format(end) 101 | await self.cursor.execute(stmt_select) 102 | eventstore = await self.cursor.fetchall() 103 | for stream in eventstore: 104 | await yield_(EventStream.make(stream[0])) 105 | 106 | async def append_to_stream( 107 | self, stream: str, eventstream: EventStream, on_save=None 108 | ): 109 | try: 110 | stored_eventstream = await self.get_stream(stream) 111 | 112 | if stored_eventstream.current_version > eventstream.initial_version: 113 | message = "The version '{0}' was expected in '{1}'".format( 114 | eventstream.initial_version, stored_eventstream 115 | ) 116 | raise VersionError(message) 117 | 118 | stored_eventstream += eventstream 119 | 120 | stmt_update = """ 121 | UPDATE {keyspace} SET data=%(data)s, updated_at=NOW(), version=%(current_version)s 122 | WHERE id = %(id)s AND version = %(initial_version)s; 123 | """.format( 124 | keyspace=self.keyspace 125 | ) 126 | await self.cursor.execute( 127 | stmt_update, 128 | { 129 | "id": str(stream), 130 | "initial_version": stored_eventstream.initial_version, 131 | "current_version": stored_eventstream.current_version, 132 | "data": stored_eventstream.json(), 133 | }, 134 | ) 135 | if self.cursor.rowcount < 1: 136 | message = "The version '{0}' was expected in '{1}'".format( 137 | eventstream.initial_version, stored_eventstream 138 | ) 139 | raise VersionError(message) 140 | await self.projections.notify_update( 141 | self.keyspace, stream, stored_eventstream 142 | ) 143 | if on_save is not None: 144 | on_save(stored_eventstream.current_version) 145 | except StreamDoesNotExist: 146 | stmt_insert = """ 147 | INSERT INTO {keyspace} (id, version, data, created_at, updated_at) 148 | VALUES (%(id)s, %(version)s, %(data)s, NOW(), NOW()) 149 | """.format( 150 | keyspace=self.keyspace 151 | ) 152 | await self.cursor.execute( 153 | stmt_insert, 154 | { 155 | "id": str(stream), 156 | "version": eventstream.current_version, 157 | "data": eventstream.json(), 158 | }, 159 | ) 160 | await self.projections.notify_create(self.keyspace, stream, eventstream) 161 | if on_save is not None: 162 | on_save(eventstream.current_version) 163 | -------------------------------------------------------------------------------- /tests/test_projections.py: -------------------------------------------------------------------------------- 1 | from operator import attrgetter 2 | 3 | import sqlalchemy as sa 4 | from async_generator import async_generator, yield_ 5 | from kant import aggregates, events, projections 6 | from kant.eventstore import EventStream 7 | from kant.projections import ProjectionError, ProjectionRouter 8 | from kant.projections.sa import SQLAlchemyProjectionAdapter 9 | from sqlalchemy.schema import CreateTable, DropTable 10 | 11 | import pytest 12 | 13 | 14 | class BankAccountCreated(events.Event): 15 | __empty_stream__ = True 16 | 17 | id = events.CUIDField(primary_key=True) 18 | owner = events.CharField() 19 | 20 | 21 | class DepositPerformed(events.Event): 22 | amount = events.DecimalField() 23 | 24 | 25 | class WithdrawalPerformed(events.Event): 26 | amount = events.DecimalField() 27 | 28 | 29 | class BankAccount(aggregates.Aggregate): 30 | id = aggregates.IntegerField() 31 | owner = aggregates.CharField() 32 | balance = aggregates.IntegerField() 33 | 34 | def apply_bank_account_created(self, event): 35 | self.id = event.id 36 | self.owner = event.owner 37 | self.balance = 0 38 | 39 | def apply_deposit_performed(self, event): 40 | self.balance += event.amount 41 | 42 | 43 | @pytest.fixture 44 | @async_generator 45 | async def statement(saconnection): 46 | statement = sa.Table( 47 | "statement", 48 | sa.MetaData(), # NOQA 49 | sa.Column("id", sa.Integer, primary_key=True), 50 | sa.Column("owner", sa.String(255)), 51 | sa.Column("balance", sa.Integer), 52 | ) 53 | await saconnection.execute(CreateTable(statement)) 54 | await yield_(statement) 55 | await saconnection.execute(DropTable(statement)) 56 | 57 | 58 | @pytest.mark.asyncio 59 | async def test_projection_should_create_projection( 60 | saconnection, eventsourcing, statement 61 | ): 62 | # arrange 63 | class Statement(projections.Projection): 64 | id = projections.IntegerField() 65 | owner = projections.CharField() 66 | balance = projections.IntegerField() 67 | 68 | def when_bank_account_created(self, event): 69 | self.id = event.id 70 | self.owner = event.owner 71 | self.balance = 0 72 | 73 | # act 74 | router = ProjectionRouter() 75 | router.add("event_store", statement, Statement) 76 | projection_adapter = SQLAlchemyProjectionAdapter(saconnection, router) 77 | eventsourcing.projections.bind(projection_adapter) 78 | 79 | bank_account = BankAccount() 80 | bank_account.dispatch(BankAccountCreated(id=123, owner="John Doe")) 81 | async with eventsourcing.open("event_store") as eventstore: 82 | await eventstore.append_to_stream(bank_account.id, bank_account.get_events()) 83 | # assert 84 | result = await saconnection.execute(statement.select()) 85 | result = list(result) 86 | assert len(result) == 1 87 | assert result[0].id == 123 88 | assert result[0].owner == "John Doe" 89 | assert result[0].balance == 0 90 | 91 | 92 | @pytest.mark.asyncio 93 | async def test_projection_should_update_projection( 94 | saconnection, eventsourcing, statement 95 | ): 96 | # arrange 97 | class Statement(projections.Projection): 98 | __keyspace__ = "event_store" 99 | id = projections.IntegerField(primary_key=True) 100 | owner = projections.CharField() 101 | balance = projections.IntegerField() 102 | 103 | def when_bank_account_created(self, event): 104 | self.id = event.id 105 | self.owner = event.owner 106 | self.balance = 0 107 | 108 | def when_deposit_performed(self, event): 109 | self.balance += event.amount 110 | 111 | # act 112 | router = ProjectionRouter() 113 | router.add("event_store", statement, Statement) 114 | projection_adapter = SQLAlchemyProjectionAdapter(saconnection, router) 115 | eventsourcing.projections.bind(projection_adapter) 116 | 117 | bank_account_1 = BankAccount() 118 | bank_account_1.dispatch(BankAccountCreated(id=321, owner="John Doe")) 119 | bank_account_2 = BankAccount() 120 | bank_account_2.dispatch(BankAccountCreated(id=789, owner="John Doe")) 121 | async with eventsourcing.open("event_store") as eventstore: 122 | await eventstore.append_to_stream( 123 | bank_account_1.id, bank_account_1.get_events(), bank_account_1.notify_save 124 | ) 125 | await eventstore.append_to_stream( 126 | bank_account_2.id, bank_account_2.get_events(), bank_account_2.notify_save 127 | ) 128 | bank_account_1.dispatch(DepositPerformed(amount=20)) 129 | bank_account_1.dispatch(DepositPerformed(amount=20)) 130 | await eventstore.append_to_stream( 131 | bank_account_1.id, bank_account_1.get_events(), bank_account_1.notify_save 132 | ) 133 | 134 | # assert 135 | result = await saconnection.execute(statement.select()) 136 | result = sorted(list(result), key=attrgetter("id")) 137 | assert len(result) == 2 138 | assert result[0].id == 321 139 | assert result[0].owner == "John Doe" 140 | assert result[0].balance == 40 141 | assert result[1].id == 789 142 | assert result[1].owner == "John Doe" 143 | assert result[1].balance == 0 144 | 145 | 146 | @pytest.mark.asyncio 147 | async def test_projection_should_raise_exception_when_update_without_primary_keys( 148 | saconnection, eventsourcing, statement 149 | ): 150 | # arrange 151 | class Statement(projections.Projection): 152 | __keyspace__ = "event_store" 153 | id = projections.IntegerField() 154 | owner = projections.CharField() 155 | balance = projections.IntegerField() 156 | 157 | def when_bank_account_created(self, event): 158 | self.id = event.id 159 | self.owner = event.owner 160 | self.balance = 0 161 | 162 | def when_deposit_performed(self, event): 163 | self.balance += event.amount 164 | 165 | # act 166 | router = ProjectionRouter() 167 | router.add("event_store", statement, Statement) 168 | projection_adapter = SQLAlchemyProjectionAdapter(saconnection, router) 169 | eventsourcing.projections.bind(projection_adapter) 170 | 171 | bank_account = BankAccount() 172 | bank_account.dispatch(BankAccountCreated(id=456, owner="John Doe")) 173 | async with eventsourcing.open("event_store") as eventstore: 174 | await eventstore.append_to_stream( 175 | bank_account.id, bank_account.get_events(), bank_account.notify_save 176 | ) 177 | bank_account.dispatch(DepositPerformed(amount=20)) 178 | bank_account.dispatch(DepositPerformed(amount=20)) 179 | # assert 180 | with pytest.raises(ProjectionError): 181 | await eventstore.append_to_stream( 182 | bank_account.id, bank_account.get_events(), bank_account.notify_save 183 | ) 184 | -------------------------------------------------------------------------------- /tests/eventstore/test_eventstore.py: -------------------------------------------------------------------------------- 1 | import json 2 | from copy import deepcopy 3 | from operator import attrgetter 4 | from os import environ 5 | 6 | from kant import events 7 | from kant.aggregates import Aggregate 8 | from kant.eventstore.stream import EventStream 9 | from kant.exceptions import StreamDoesNotExist, VersionError 10 | 11 | import pytest 12 | 13 | 14 | class BankAccountCreated(events.Event): 15 | __empty_stream__ = True 16 | 17 | id = events.CUIDField(primary_key=True) 18 | owner = events.CharField() 19 | 20 | 21 | class DepositPerformed(events.Event): 22 | amount = events.DecimalField() 23 | 24 | 25 | class WithdrawalPerformed(events.Event): 26 | amount = events.DecimalField() 27 | 28 | 29 | class MyObjectCreated(events.Event): 30 | id = events.CUIDField(primary_key=True) 31 | owner = events.CharField() 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_save_should_create_event_store(dbsession, eventsourcing): 36 | # arrange 37 | aggregate_id = "052c21b6-aab9-4311-b954-518cd04f704c" 38 | events = EventStream([BankAccountCreated(id=aggregate_id, owner="John Doe")]) 39 | # act 40 | async with eventsourcing.open("event_store") as eventstore: 41 | await eventstore.append_to_stream(aggregate_id, events) 42 | result = await eventstore.get_stream(aggregate_id) 43 | 44 | # assert 45 | async with dbsession.cursor() as cursor: 46 | stmt = """ 47 | SELECT event_store.id, event_store.data, event_store.created_at 48 | FROM event_store WHERE event_store.id = %(id)s 49 | """ 50 | result = await cursor.execute(stmt, {"id": aggregate_id}) 51 | event_store = await cursor.fetchone() 52 | assert event_store is not None 53 | assert cursor.rowcount == 1 54 | # id 55 | assert event_store[0] == aggregate_id 56 | assert event_store[1][0]["$type"] == "BankAccountCreated" 57 | assert event_store[1][0]["$version"] == 0 58 | assert event_store[1][0]["id"] == aggregate_id 59 | assert event_store[1][0]["owner"] == "John Doe" 60 | assert event_store[2] is not None 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_eventstore_should_fetch_one_stream(dbsession, eventsourcing): 65 | # arrange 66 | stmt = """ 67 | INSERT INTO event_store (id, data, created_at, updated_at) 68 | VALUES (%(id)s, %(data)s, NOW(), NOW()) 69 | """ 70 | aggregate_id = "e0fbeecc-d68f-45b1-83c7-5cbc65f78af7" 71 | # act 72 | async with dbsession.cursor() as cursor: 73 | await cursor.execute( 74 | stmt, 75 | { 76 | "id": aggregate_id, 77 | "data": json.dumps( 78 | [ 79 | { 80 | "$type": "MyObjectCreated", 81 | "$version": 0, 82 | "id": aggregate_id, 83 | "owner": "John Doe", 84 | } 85 | ] 86 | ), 87 | }, 88 | ) 89 | # act 90 | async with eventsourcing.open("event_store") as eventstore: 91 | stored_events = await eventstore.get_stream(aggregate_id) 92 | # assert 93 | assert len(stored_events) == 1 94 | event = list(stored_events)[0] 95 | assert isinstance(event, MyObjectCreated) 96 | assert event.version == 0 97 | assert event.id == aggregate_id 98 | assert event.owner == "John Doe" 99 | 100 | 101 | @pytest.mark.asyncio 102 | async def test_eventstore_should_fetch_all_streams(dbsession, eventsourcing): 103 | # arrange 104 | stmt = """ 105 | INSERT INTO event_store (id, data, created_at, updated_at) 106 | VALUES (%(id)s, %(data)s, NOW(), NOW()) 107 | """ 108 | aggregate1_id = "1" 109 | aggregate2_id = "2" 110 | # act 111 | async with dbsession.cursor() as cursor: 112 | await cursor.execute( 113 | stmt, 114 | { 115 | "id": aggregate1_id, 116 | "data": json.dumps( 117 | [ 118 | { 119 | "$type": "BankAccountCreated", 120 | "$version": 0, 121 | "id": aggregate1_id, 122 | "owner": "John Doe", 123 | } 124 | ] 125 | ), 126 | }, 127 | ) 128 | await cursor.execute( 129 | stmt, 130 | { 131 | "id": aggregate2_id, 132 | "data": json.dumps( 133 | [ 134 | { 135 | "$type": "BankAccountCreated", 136 | "$version": 0, 137 | "id": aggregate2_id, 138 | "owner": "Tim Clock", 139 | }, 140 | {"$type": "DepositPerformed", "$version": 1, "amount": 20}, 141 | ] 142 | ), 143 | }, 144 | ) 145 | # act 146 | async with eventsourcing.open("event_store") as eventstore: 147 | stored_eventstreams = [] 148 | async for stream in eventstore.all_streams(): 149 | stored_eventstreams.append(stream) 150 | stored_eventstreams = sorted( 151 | stored_eventstreams, key=attrgetter("current_version") 152 | ) 153 | 154 | # assert 155 | assert len(stored_eventstreams) == 2 156 | eventstream_1 = list(stored_eventstreams[0]) 157 | eventstream_2 = list(stored_eventstreams[1]) 158 | assert eventstream_1[0].version == 0 159 | assert eventstream_1[0].id == aggregate1_id 160 | assert eventstream_1[0].owner == "John Doe" 161 | assert eventstream_2[0].version == 0 162 | assert eventstream_2[0].id == aggregate2_id 163 | assert eventstream_2[0].owner == "Tim Clock" 164 | assert eventstream_2[1].version == 1 165 | assert eventstream_2[1].amount == 20 166 | 167 | 168 | @pytest.mark.asyncio 169 | async def test_get_should_raise_exception_when_not_found(eventsourcing): 170 | # arrange 171 | aggregate_id = "f2283f9d-9ed2-4385-a614-53805725cbac", 172 | # act and assert 173 | async with eventsourcing.open("event_store") as eventstore: 174 | with pytest.raises(StreamDoesNotExist): 175 | stored_events = await eventstore.get_stream(aggregate_id) 176 | 177 | 178 | @pytest.mark.asyncio 179 | async def test_eventstore_should_raise_version_error(dbsession, eventsourcing): 180 | # arrange 181 | aggregate_id = "f2283f9d-9ed2-4385-a614-53805725cbac" 182 | events_base = EventStream([BankAccountCreated(id=aggregate_id, owner="John Doe")]) 183 | events = EventStream([DepositPerformed(amount=20)]) 184 | async with eventsourcing.open("event_store") as eventstore: 185 | await eventstore.append_to_stream(aggregate_id, events_base) 186 | 187 | # act 188 | async with dbsession.cursor() as cursor: 189 | stmt = """ 190 | UPDATE event_store SET version=%(version)s 191 | WHERE id = %(id)s; 192 | """ 193 | await cursor.execute(stmt, {"id": aggregate_id, "version": 10}) 194 | # assert 195 | with pytest.raises(VersionError): 196 | async with eventsourcing.open("event_store") as eventstore: 197 | await eventstore.append_to_stream(aggregate_id, events) 198 | -------------------------------------------------------------------------------- /tests/test_aggregates.py: -------------------------------------------------------------------------------- 1 | from kant import aggregates, events 2 | from kant.eventstore import EventStream 3 | 4 | import pytest 5 | 6 | 7 | class BankAccountCreated(events.Event): 8 | __empty_stream__ = True 9 | 10 | id = events.CUIDField(primary_key=True) 11 | owner = events.CharField() 12 | 13 | 14 | class DepositPerformed(events.Event): 15 | amount = events.DecimalField() 16 | 17 | 18 | class WithdrawalPerformed(events.Event): 19 | amount = events.DecimalField() 20 | 21 | 22 | @pytest.mark.asyncio 23 | async def test_aggregate_should_apply_one_event(dbsession): 24 | # arrange 25 | class BankAccount(aggregates.Aggregate): 26 | id = aggregates.IntegerField() 27 | owner = aggregates.CharField() 28 | balance = aggregates.IntegerField() 29 | 30 | def apply_bank_account_created(self, event): 31 | self.id = event.id 32 | self.owner = event.owner 33 | self.balance = 0 34 | 35 | bank_account = BankAccount() 36 | bank_account_created = BankAccountCreated(id=123, owner="John Doe") 37 | # act 38 | bank_account.dispatch(bank_account_created) 39 | # assert 40 | assert bank_account.version == -1 41 | assert bank_account.current_version == 0 42 | assert bank_account.id == 123 43 | assert bank_account.owner == "John Doe" 44 | assert bank_account.balance == 0 45 | 46 | 47 | @pytest.mark.asyncio 48 | async def test_aggregate_should_apply_many_events(dbsession): 49 | # arrange 50 | class BankAccount(aggregates.Aggregate): 51 | id = aggregates.IntegerField() 52 | owner = aggregates.CharField() 53 | balance = aggregates.IntegerField() 54 | 55 | def apply_bank_account_created(self, event): 56 | self.id = event.get("id") 57 | self.owner = event.get("owner") 58 | self.balance = 0 59 | 60 | def apply_deposit_performed(self, event): 61 | self.balance += event.get("amount") 62 | 63 | bank_account_created = BankAccountCreated(id=123, owner="John Doe") 64 | deposit_performed = DepositPerformed(amount=20) 65 | # act 66 | bank_account = BankAccount() 67 | bank_account.dispatch(bank_account_created) 68 | bank_account.dispatch(deposit_performed) 69 | # assert 70 | assert bank_account.version == -1 71 | assert bank_account.current_version == 1 72 | assert bank_account.id == 123 73 | assert bank_account.owner == "John Doe" 74 | assert bank_account.balance == 20 75 | 76 | 77 | @pytest.mark.asyncio 78 | async def test_aggregate_should_apply_event_list(dbsession): 79 | # arrange 80 | class BankAccount(aggregates.Aggregate): 81 | id = aggregates.IntegerField() 82 | owner = aggregates.CharField() 83 | balance = aggregates.IntegerField() 84 | 85 | def apply_bank_account_created(self, event): 86 | self.id = event.get("id") 87 | self.owner = event.get("owner") 88 | self.balance = 0 89 | 90 | def apply_deposit_performed(self, event): 91 | self.balance += event.get("amount") 92 | 93 | bank_account_created = BankAccountCreated(id=123, owner="John Doe") 94 | deposit_performed = DepositPerformed(amount=20) 95 | # act 96 | bank_account = BankAccount() 97 | bank_account.dispatch([bank_account_created, deposit_performed]) 98 | # assert 99 | assert bank_account.version == -1 100 | assert bank_account.current_version == 1 101 | assert bank_account.id == 123 102 | assert bank_account.owner == "John Doe" 103 | assert bank_account.balance == 20 104 | 105 | 106 | @pytest.mark.asyncio 107 | async def test_aggregate_should_load_events(dbsession): 108 | # arrange 109 | 110 | class BankAccount(aggregates.Aggregate): 111 | id = aggregates.IntegerField() 112 | owner = aggregates.CharField() 113 | balance = aggregates.IntegerField() 114 | 115 | def apply_bank_account_created(self, event): 116 | self.id = event.get("id") 117 | self.owner = event.get("owner") 118 | self.balance = 0 119 | 120 | def apply_deposit_performed(self, event): 121 | self.balance += event.get("amount") 122 | 123 | events = EventStream( 124 | [ 125 | BankAccountCreated(id=123, owner="John Doe", version=0), 126 | DepositPerformed(amount=20, version=1), 127 | DepositPerformed(amount=20, version=2), 128 | ] 129 | ) 130 | # act 131 | bank_account = BankAccount() 132 | bank_account.fetch_events(events) 133 | # assert 134 | assert bank_account.version == 2 135 | assert bank_account.current_version == 2 136 | assert bank_account.id == 123 137 | assert bank_account.owner == "John Doe" 138 | assert bank_account.balance == 40 139 | 140 | 141 | @pytest.mark.asyncio 142 | async def test_aggregate_should_apply_event_after_load_events(dbsession): 143 | # arrange 144 | 145 | class BankAccount(aggregates.Aggregate): 146 | id = aggregates.IntegerField() 147 | owner = aggregates.CharField() 148 | balance = aggregates.IntegerField() 149 | 150 | def apply_bank_account_created(self, event): 151 | self.id = event.get("id") 152 | self.owner = event.get("owner") 153 | self.balance = 0 154 | 155 | def apply_deposit_performed(self, event): 156 | self.balance += event.get("amount") 157 | 158 | events = EventStream([BankAccountCreated(id=123, owner="John Doe", version=0)]) 159 | deposit_performed = DepositPerformed(amount=20) 160 | # act 161 | bank_account = BankAccount() 162 | bank_account.fetch_events(events) 163 | bank_account.dispatch(deposit_performed) 164 | # assert 165 | assert bank_account.version == 0 166 | assert bank_account.current_version == 1 167 | assert bank_account.id == 123 168 | assert bank_account.owner == "John Doe" 169 | assert bank_account.balance == 20 170 | 171 | 172 | @pytest.mark.asyncio 173 | async def test_aggregate_should_be_created_from_events(dbsession): 174 | # arrange 175 | 176 | class BankAccount(aggregates.Aggregate): 177 | id = aggregates.IntegerField() 178 | owner = aggregates.CharField() 179 | balance = aggregates.IntegerField() 180 | 181 | def apply_bank_account_created(self, event): 182 | self.id = event.get("id") 183 | self.owner = event.get("owner") 184 | self.balance = 0 185 | 186 | def apply_deposit_performed(self, event): 187 | self.balance += event.get("amount") 188 | 189 | events = EventStream([BankAccountCreated(id=123, owner="John Doe", version=0)]) 190 | deposit_performed = DepositPerformed(amount=20) 191 | # act 192 | bank_account = BankAccount.from_stream(events) 193 | bank_account.dispatch(deposit_performed) 194 | # assert 195 | assert bank_account.version == 0 196 | assert bank_account.current_version == 1 197 | assert bank_account.id == 123 198 | assert bank_account.owner == "John Doe" 199 | assert bank_account.balance == 20 200 | 201 | 202 | @pytest.mark.asyncio 203 | async def test_aggregate_should_return_new_events(dbsession): 204 | # arrange 205 | 206 | class BankAccount(aggregates.Aggregate): 207 | id = aggregates.IntegerField() 208 | owner = aggregates.CharField() 209 | balance = aggregates.IntegerField() 210 | 211 | def apply_bank_account_created(self, event): 212 | self.id = event.get("id") 213 | self.owner = event.get("owner") 214 | self.balance = 0 215 | 216 | def apply_deposit_performed(self, event): 217 | self.balance += event.get("amount") 218 | 219 | events = EventStream([BankAccountCreated(id=123, owner="John Doe", version=0)]) 220 | deposit_performed = DepositPerformed(amount=20) 221 | 222 | # act 223 | bank_account = BankAccount() 224 | bank_account.fetch_events(events) 225 | bank_account.dispatch(deposit_performed) 226 | result = list(bank_account.get_events()) 227 | # assert 228 | assert len(result) == 1 229 | assert result[0].version == 1 230 | assert result[0].amount == 20 231 | 232 | 233 | @pytest.mark.asyncio 234 | async def test_aggregate_should_return_all_events(dbsession): 235 | # arrange 236 | class BankAccount(aggregates.Aggregate): 237 | id = aggregates.IntegerField() 238 | owner = aggregates.CharField() 239 | balance = aggregates.IntegerField() 240 | 241 | def apply_bank_account_created(self, event): 242 | self.id = event.get("id") 243 | self.owner = event.get("owner") 244 | self.balance = 0 245 | 246 | def apply_deposit_performed(self, event): 247 | self.balance += event.get("amount") 248 | 249 | events = EventStream([BankAccountCreated(id=123, owner="John Doe", version=0)]) 250 | deposit_performed = DepositPerformed(amount=20) 251 | # act 252 | bank_account = BankAccount() 253 | bank_account.fetch_events(events) 254 | bank_account.dispatch(deposit_performed) 255 | result = list(bank_account.all_events()) 256 | # assert 257 | assert len(result) == 2 258 | assert result[0].version == 0 259 | assert result[0].id == 123 260 | assert result[0].owner == "John Doe" 261 | assert result[1].version == 1 262 | assert result[1].amount == 20 263 | 264 | 265 | @pytest.mark.asyncio 266 | async def test_aggregate_should_return_stored_events(dbsession): 267 | # arrange 268 | class BankAccount(aggregates.Aggregate): 269 | id = aggregates.IntegerField() 270 | owner = aggregates.CharField() 271 | balance = aggregates.IntegerField() 272 | 273 | def apply_bank_account_created(self, event): 274 | self.id = event.get("id") 275 | self.owner = event.get("owner") 276 | self.balance = 0 277 | 278 | def apply_deposit_performed(self, event): 279 | self.balance += event.get("amount") 280 | 281 | events = EventStream([BankAccountCreated(id=123, owner="John Doe", version=0)]) 282 | deposit_performed = DepositPerformed(amount=20) 283 | # act 284 | bank_account = BankAccount() 285 | bank_account.fetch_events(events) 286 | bank_account.dispatch(deposit_performed) 287 | result = list(bank_account.stored_events()) 288 | # assert 289 | assert len(result) == 1 290 | assert result[0].version == 0 291 | assert result[0].id == 123 292 | assert result[0].owner == "John Doe" 293 | 294 | 295 | @pytest.mark.asyncio 296 | async def test_aggregate_should_decode_to_json(dbsession): 297 | # arrange 298 | class BankAccount(aggregates.Aggregate): 299 | id = aggregates.IntegerField() 300 | owner = aggregates.CharField() 301 | balance = aggregates.IntegerField() 302 | 303 | def apply_bank_account_created(self, event): 304 | self.id = event.id 305 | self.owner = event.owner 306 | self.balance = 0 307 | 308 | def apply_deposit_performed(self, event): 309 | self.balance += event.amount 310 | 311 | events = EventStream([BankAccountCreated(id=123, owner="John Doe", version=0)]) 312 | deposit_performed = DepositPerformed(amount=20) 313 | # act 314 | bank_account = BankAccount() 315 | bank_account.fetch_events(events) 316 | bank_account.dispatch(deposit_performed) 317 | result = bank_account.json() 318 | # assert 319 | expected_result = '{"balance": 20, "id": 123, "owner": "John Doe"}' 320 | assert isinstance(result, str) 321 | assert result == expected_result 322 | 323 | 324 | @pytest.mark.asyncio 325 | async def test_aggregate_should_decode_to_json_filtering_by_fields(dbsession): 326 | # arrange 327 | class BankAccount(aggregates.Aggregate): 328 | id = aggregates.IntegerField() 329 | owner = aggregates.CharField() 330 | balance = aggregates.IntegerField() 331 | 332 | def apply_bank_account_created(self, event): 333 | self.id = event.id 334 | self.owner = event.owner 335 | self.balance = 0 336 | 337 | def apply_deposit_performed(self, event): 338 | self.balance += event.amount 339 | 340 | events = EventStream([BankAccountCreated(id=123, owner="John Doe", version=0)]) 341 | deposit_performed = DepositPerformed(amount=20) 342 | # act 343 | bank_account = BankAccount() 344 | bank_account.fetch_events(events) 345 | bank_account.dispatch(deposit_performed) 346 | result = bank_account.json(only=("id",)) 347 | # assert 348 | expected_result = '{"id": 123}' 349 | assert isinstance(result, str) 350 | assert result == expected_result 351 | 352 | 353 | @pytest.mark.asyncio 354 | async def test_aggregate_should_save_to_eventstore(dbsession, eventsourcing): 355 | # arrange 356 | class BankAccount(aggregates.Aggregate): 357 | __keyspace__ = "event_store" 358 | id = aggregates.IntegerField(primary_key=True) 359 | owner = aggregates.CharField() 360 | balance = aggregates.IntegerField() 361 | 362 | def apply_bank_account_created(self, event): 363 | self.id = event.get("id") 364 | self.owner = event.get("owner") 365 | self.balance = 0 366 | 367 | def apply_deposit_performed(self, event): 368 | self.balance += event.get("amount") 369 | 370 | bank_account_created = BankAccountCreated(id=123, owner="John Doe") 371 | deposit_performed = DepositPerformed(amount=20) 372 | # act 373 | bank_account = BankAccount() 374 | bank_account.dispatch([bank_account_created, deposit_performed]) 375 | await bank_account.save() 376 | async with eventsourcing.open("event_store") as eventstore: 377 | stored_events = await eventstore.get_stream(bank_account.id) 378 | stored_bank_account = BankAccount.from_stream(stored_events) 379 | # assert 380 | assert stored_bank_account.version == 1 381 | assert stored_bank_account.current_version == 1 382 | assert stored_bank_account.id == 123 383 | assert stored_bank_account.owner == "John Doe" 384 | assert stored_bank_account.balance == 20 385 | 386 | 387 | @pytest.mark.asyncio 388 | async def test_manager_should_get_aggregate(dbsession, eventsourcing): 389 | # arrange 390 | class BankAccount(aggregates.Aggregate): 391 | __keyspace__ = "event_store" 392 | id = aggregates.IntegerField(primary_key=True) 393 | owner = aggregates.CharField() 394 | balance = aggregates.IntegerField() 395 | 396 | def apply_bank_account_created(self, event): 397 | self.id = event.get("id") 398 | self.owner = event.get("owner") 399 | self.balance = 0 400 | 401 | def apply_deposit_performed(self, event): 402 | self.balance += event.get("amount") 403 | 404 | bank_account_created = BankAccountCreated(id=123, owner="John Doe") 405 | deposit_performed = DepositPerformed(amount=20) 406 | # act 407 | bank_account = BankAccount() 408 | bank_account.dispatch([bank_account_created, deposit_performed]) 409 | async with eventsourcing.open("event_store") as eventstore: 410 | await eventstore.append_to_stream(bank_account.id, bank_account.get_events()) 411 | stored_bank_account = await BankAccount.objects.get(bank_account_created.id) 412 | # assert 413 | assert stored_bank_account.version == 1 414 | assert stored_bank_account.current_version == 1 415 | assert stored_bank_account.id == 123 416 | assert stored_bank_account.owner == "John Doe" 417 | assert stored_bank_account.balance == 20 418 | 419 | 420 | @pytest.mark.asyncio 421 | async def test_manager_aggregate_should_find_all_aggregates(dbsession, eventsourcing): 422 | # arrange 423 | class BankAccount(aggregates.Aggregate): 424 | __keyspace__ = "event_store" 425 | id = aggregates.IntegerField(primary_key=True) 426 | owner = aggregates.CharField() 427 | balance = aggregates.IntegerField() 428 | 429 | def apply_bank_account_created(self, event): 430 | self.id = event.get("id") 431 | self.owner = event.get("owner") 432 | self.balance = 0 433 | 434 | def apply_deposit_performed(self, event): 435 | self.balance += event.get("amount") 436 | 437 | # act 438 | bank_account_1 = BankAccount() 439 | bank_account_1.dispatch( 440 | [BankAccountCreated(id=123, owner="John Doe"), DepositPerformed(amount=20)] 441 | ) 442 | await bank_account_1.save() 443 | bank_account_2 = BankAccount() 444 | bank_account_2.dispatch( 445 | [ 446 | BankAccountCreated(id=456, owner="John Doe"), 447 | DepositPerformed(amount=20), 448 | DepositPerformed(amount=20), 449 | ] 450 | ) 451 | await bank_account_2.save() 452 | stored_aggregates = [] 453 | async for aggregate in BankAccount.objects.all(): 454 | stored_aggregates.append(aggregate) 455 | # assert 456 | assert stored_aggregates[0].version == 1 457 | assert stored_aggregates[0].current_version == 1 458 | assert stored_aggregates[0].id == 123 459 | assert stored_aggregates[0].owner == "John Doe" 460 | assert stored_aggregates[0].balance == 20 461 | assert stored_aggregates[1].version == 2 462 | assert stored_aggregates[1].current_version == 2 463 | assert stored_aggregates[1].id == 456 464 | assert stored_aggregates[1].owner == "John Doe" 465 | assert stored_aggregates[1].balance == 40 466 | 467 | 468 | @pytest.mark.asyncio 469 | async def test_aggregate_should_refresh_from_db(dbsession, eventsourcing): 470 | # arrange 471 | class BankAccount(aggregates.Aggregate): 472 | __keyspace__ = "event_store" 473 | id = aggregates.IntegerField(primary_key=True) 474 | owner = aggregates.CharField() 475 | balance = aggregates.IntegerField() 476 | 477 | def apply_bank_account_created(self, event): 478 | self.id = event.get("id") 479 | self.owner = event.get("owner") 480 | self.balance = 0 481 | 482 | def apply_deposit_performed(self, event): 483 | self.balance += event.get("amount") 484 | 485 | # act 486 | bank_account = BankAccount() 487 | bank_account.dispatch( 488 | [BankAccountCreated(id=123, owner="John Doe"), DepositPerformed(amount=20)] 489 | ) 490 | await bank_account.save() 491 | stored_bank_account_1 = await BankAccount.objects.get(bank_account.id) 492 | stored_bank_account_2 = await BankAccount.objects.get(bank_account.id) 493 | stored_bank_account_1.dispatch(DepositPerformed(amount=20)) 494 | await stored_bank_account_1.save() 495 | await stored_bank_account_2.refresh_from_db() 496 | # assert 497 | assert stored_bank_account_2.version == 2 498 | assert stored_bank_account_2.current_version == 2 499 | assert stored_bank_account_2.id == 123 500 | assert stored_bank_account_2.owner == "John Doe" 501 | assert stored_bank_account_2.balance == 40 502 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "df44c385288d4fc4bad4e4512687e96732e5b11fe100bfa57c50c5ab92ef49ff" 5 | }, 6 | "host-environment-markers": { 7 | "implementation_name": "cpython", 8 | "implementation_version": "3.6.5", 9 | "os_name": "posix", 10 | "platform_machine": "x86_64", 11 | "platform_python_implementation": "CPython", 12 | "platform_release": "4.13.0-39-generic", 13 | "platform_system": "Linux", 14 | "platform_version": "#44~16.04.1-Ubuntu SMP Thu Apr 5 16:43:10 UTC 2018", 15 | "python_full_version": "3.6.5", 16 | "python_version": "3.6", 17 | "sys_platform": "linux" 18 | }, 19 | "pipfile-spec": 6, 20 | "requires": {}, 21 | "sources": [ 22 | { 23 | "name": "pypi", 24 | "url": "https://pypi.python.org/simple", 25 | "verify_ssl": true 26 | } 27 | ] 28 | }, 29 | "default": {}, 30 | "develop": { 31 | "a14ec56": { 32 | "editable": true, 33 | "path": "." 34 | }, 35 | "aiopg": { 36 | "hashes": [ 37 | "sha256:1d8393eb0a082009169a72e0bd9c63570924f3cb5bbe220215f62e2db48c41e7", 38 | "sha256:2a6bbc7dfc6e3b9248e0dc7bd573050bab693f30f00d4889fa011d3bc94603ca" 39 | ], 40 | "version": "==0.13.2" 41 | }, 42 | "alabaster": { 43 | "hashes": [ 44 | "sha256:2eef172f44e8d301d25aff8068fddd65f767a3f04b5f15b0f4922f113aa1c732", 45 | "sha256:37cdcb9e9954ed60912ebc1ca12a9d12178c26637abdf124e3cde2341c257fe0" 46 | ], 47 | "version": "==0.7.10" 48 | }, 49 | "argh": { 50 | "hashes": [ 51 | "sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3", 52 | "sha256:e9535b8c84dc9571a48999094fda7f33e63c3f1b74f3e5f3ac0105a58405bb65" 53 | ], 54 | "version": "==0.26.2" 55 | }, 56 | "async-generator": { 57 | "hashes": [ 58 | "sha256:2f45541002a14f80fffc6d52788f92470ff0d9bfe81c434ea8cd60babe43de9e", 59 | "sha256:b7d5465c6174fe86dba498ececb175f93a6097ffb7cc91946405e1f05b848371" 60 | ], 61 | "version": "==1.9" 62 | }, 63 | "asyncio-extras": { 64 | "hashes": [ 65 | "sha256:936994ffe4ebe7ba0b9063002af7e601d1450e6bf031da630716f1b9a0fc3348", 66 | "sha256:8f2bf0edc37530e0dafcb2c0a8d8303a4ab965febff36bf056baffb6ac939ce9" 67 | ], 68 | "version": "==1.3.0" 69 | }, 70 | "babel": { 71 | "hashes": [ 72 | "sha256:ad209a68d7162c4cff4b29cdebe3dec4cef75492df501b0049a9433c96ce6f80", 73 | "sha256:8ce4cb6fdd4393edd323227cba3a077bceb2a6ce5201c902c65e730046f41f14" 74 | ], 75 | "version": "==2.5.3" 76 | }, 77 | "certifi": { 78 | "hashes": [ 79 | "sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0", 80 | "sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7" 81 | ], 82 | "version": "==2018.4.16" 83 | }, 84 | "chardet": { 85 | "hashes": [ 86 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691", 87 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae" 88 | ], 89 | "version": "==3.0.4" 90 | }, 91 | "cuid.py": { 92 | "hashes": [ 93 | "sha256:0597991a97f3fa0c6d2f4222dfee97520f628bf3c0065f42b2422aff167ba052" 94 | ], 95 | "version": "==0.2" 96 | }, 97 | "detox": { 98 | "hashes": [ 99 | "sha256:cb24895a0e4f95c0bcb1087a201c453600e075568af00848e91518fb2b984568", 100 | "sha256:f3119bca4444f1e8a1d7189b064c52cfdd9a89ad3a1c921d78b49bf7f5dc5b1b" 101 | ], 102 | "version": "==0.12" 103 | }, 104 | "docutils": { 105 | "hashes": [ 106 | "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6", 107 | "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", 108 | "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274" 109 | ], 110 | "version": "==0.14" 111 | }, 112 | "enum34": { 113 | "hashes": [ 114 | "sha256:6bd0f6ad48ec2aa117d3d141940d484deccda84d4fcd884f5c3d93c23ecd8c79", 115 | "sha256:644837f692e5f550741432dd3f223bbb9852018674981b1664e5dc339387588a", 116 | "sha256:8ad8c4783bf61ded74527bffb48ed9b54166685e4230386a9ed9b1279e2df5b1", 117 | "sha256:2d81cbbe0e73112bdfe6ef8576f2238f2ba27dd0d55752a776c41d38b7da2850" 118 | ], 119 | "markers": "python_version < '3.4'", 120 | "version": "==1.1.6" 121 | }, 122 | "eventlet": { 123 | "hashes": [ 124 | "sha256:87b2afb22fb7601f77e9cb9481e3e8c557e8cac9df69b5b2dc0b38ec5c23d67a", 125 | "sha256:46b7e565aaa06b5d1ba435cb355e09cf3002e34dc269671c93c960f9879d30e0" 126 | ], 127 | "version": "==0.22.1" 128 | }, 129 | "greenlet": { 130 | "hashes": [ 131 | "sha256:50643fd6d54fd919f9a0a577c5f7b71f5d21f0959ab48767bd4bb73ae0839500", 132 | "sha256:c7b04a6dc74087b1598de8d713198de4718fa30ec6cbb84959b26426c198e041", 133 | "sha256:b6ef0cabaf5a6ecb5ac122e689d25ba12433a90c7b067b12e5f28bdb7fb78254", 134 | "sha256:fcfadaf4bf68a27e5dc2f42cbb2f4b4ceea9f05d1d0b8f7787e640bed2801634", 135 | "sha256:b417bb7ff680d43e7bd7a13e2e08956fa6acb11fd432f74c97b7664f8bdb6ec1", 136 | "sha256:769b740aeebd584cd59232be84fdcaf6270b8adc356596cdea5b2152c82caaac", 137 | "sha256:c2de19c88bdb0366c976cc125dca1002ec1b346989d59524178adfd395e62421", 138 | "sha256:5b49b3049697aeae17ef7bf21267e69972d9e04917658b4e788986ea5cc518e8", 139 | "sha256:09ef2636ea35782364c830f07127d6c7a70542b178268714a9a9ba16318e7e8b", 140 | "sha256:f8f2a0ae8de0b49c7b5b2daca4f150fdd9c1173e854df2cce3b04123244f9f45", 141 | "sha256:1b7df09c6598f5cfb40f843ade14ed1eb40596e75cd79b6fa2efc750ba01bb01", 142 | "sha256:75c413551a436b462d5929255b6dc9c0c3c2b25cbeaee5271a56c7fda8ca49c0", 143 | "sha256:58798b5d30054bb4f6cf0f712f08e6092df23a718b69000786634a265e8911a9", 144 | "sha256:42118bf608e0288e35304b449a2d87e2ba77d1e373e8aa221ccdea073de026fa", 145 | "sha256:ad2383d39f13534f3ca5c48fe1fc0975676846dc39c2cece78c0f1f9891418e0", 146 | "sha256:1fff21a2da5f9e03ddc5bd99131a6b8edf3d7f9d6bc29ba21784323d17806ed7", 147 | "sha256:0fef83d43bf87a5196c91e73cb9772f945a4caaff91242766c5916d1dd1381e4" 148 | ], 149 | "version": "==0.4.13" 150 | }, 151 | "idna": { 152 | "hashes": [ 153 | "sha256:8c7309c718f94b3a625cb648ace320157ad16ff131ae0af362c9f21b80ef6ec4", 154 | "sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f" 155 | ], 156 | "version": "==2.6" 157 | }, 158 | "imagesize": { 159 | "hashes": [ 160 | "sha256:3620cc0cadba3f7475f9940d22431fc4d407269f1be59ec9b8edcca26440cf18", 161 | "sha256:5b326e4678b6925158ccc66a9fa3122b6106d7c876ee32d7de6ce59385b96315" 162 | ], 163 | "version": "==1.0.0" 164 | }, 165 | "inflection": { 166 | "hashes": [ 167 | "sha256:18ea7fb7a7d152853386523def08736aa8c32636b047ade55f7578c4edeb16ca" 168 | ], 169 | "version": "==0.3.1" 170 | }, 171 | "jinja2": { 172 | "hashes": [ 173 | "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", 174 | "sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" 175 | ], 176 | "version": "==2.10" 177 | }, 178 | "livereload": { 179 | "hashes": [ 180 | "sha256:583179dc8d49b040a9da79bd33de59e160d2a8802b939e304eb359a4419f6498", 181 | "sha256:dd4469a8f5a6833576e9f5433f1439c306de15dbbfeceabd32479b1123380fa5" 182 | ], 183 | "version": "==2.5.2" 184 | }, 185 | "markupsafe": { 186 | "hashes": [ 187 | "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" 188 | ], 189 | "version": "==1.0" 190 | }, 191 | "packaging": { 192 | "hashes": [ 193 | "sha256:e9215d2d2535d3ae866c3d6efc77d5b24a0192cce0ff20e42896cc0664f889c0", 194 | "sha256:f019b770dd64e585a99714f1fd5e01c7a8f11b45635aa953fd41c689a657375b" 195 | ], 196 | "version": "==17.1" 197 | }, 198 | "pathtools": { 199 | "hashes": [ 200 | "sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0" 201 | ], 202 | "version": "==0.1.2" 203 | }, 204 | "pluggy": { 205 | "hashes": [ 206 | "sha256:d345c8fe681115900d6da8d048ba67c25df42973bda370783cd58826442dcd7c", 207 | "sha256:e160a7fcf25762bb60efc7e171d4497ff1d8d2d75a3d0df7a21b76821ecbf5c5", 208 | "sha256:7f8ae7f5bdf75671a718d2daf0a64b7885f74510bcd98b1a0bb420eb9a9d0cff" 209 | ], 210 | "version": "==0.6.0" 211 | }, 212 | "port-for": { 213 | "hashes": [ 214 | "sha256:b16a84bb29c2954db44c29be38b17c659c9c27e33918dec16b90d375cc596f1c" 215 | ], 216 | "version": "==0.3.1" 217 | }, 218 | "psycopg2": { 219 | "hashes": [ 220 | "sha256:aeaba399254ca79c299d9fe6aa811d3c3eac61458dee10270de7f4e71c624998", 221 | "sha256:1d90379d01d0dc50ae9b40c863933d87ff82d51dd7d52cea5d1cb7019afd72cd", 222 | "sha256:36030ca7f4b4519ee4f52a74edc4ec73c75abfb6ea1d80ac7480953d1c0aa3c3", 223 | "sha256:7cbc3b21ce2f681ca9ad2d8c0901090b23a30c955e980ebf1006d41f37068a95", 224 | "sha256:b178e0923c93393e16646155794521e063ec17b7cc9f943f15b7d4b39776ea2c", 225 | "sha256:fe6a7f87356116f5ea840c65b032af17deef0e1a5c34013a2962dd6f99b860dd", 226 | "sha256:6f302c486132f8dd11f143e919e236ea4467d53bf18c451cac577e6988ecbd05", 227 | "sha256:888bba7841116e529f407f15c6d28fe3ef0760df8c45257442ec2f14f161c871", 228 | "sha256:932a4c101af007cb3132b1f8a9ffef23386acc53dad46536dc5ba43a3235ae02", 229 | "sha256:179c52eb870110a8c1b460c86d4f696d58510ea025602cd3f81453746fccb94f", 230 | "sha256:33f9e1032095e1436fa9ec424abcbd4c170da934fb70e391c5d78275d0307c75", 231 | "sha256:092a80da1b052a181b6e6c765849c9b32d46c5dac3b81bf8c9b83e697f3cdbe8", 232 | "sha256:f3d3a88128f0c219bdc5b2d9ccd496517199660cea021c560a3252116df91cbd", 233 | "sha256:19983b77ec1fc2a210092aa0333ee48811fd9fb5f194c6cd5b927ed409aea5f8", 234 | "sha256:027ae518d0e3b8fff41990e598bc7774c3d08a3a20e9ecc0b59fb2aaaf152f7f", 235 | "sha256:363fbbf4189722fc46779be1fad2597e2c40b3f577dc618f353a46391cf5d235", 236 | "sha256:d74cf9234ba76426add5e123449be08993a9b13ff434c6efa3a07caa305a619f", 237 | "sha256:32702e3bd8bfe12b36226ba9846ed9e22336fc4bd710039d594b36bd432ae255", 238 | "sha256:8eb94c0625c529215b53c08fb4e461546e2f3fc96a49c13d5474b5ad7aeab6cf", 239 | "sha256:8ebba5314c609a05c6955e5773c7e0e57b8dd817e4f751f30de729be58fa5e78", 240 | "sha256:27467fd5af1dcc0a82d72927113b8f92da8f44b2efbdb8906bd76face95b596d", 241 | "sha256:b68e89bb086a9476fa85298caab43f92d0a6af135a5f433d1f6b6d82cafa7b55", 242 | "sha256:0b9851e798bae024ed1a2a6377a8dab4b8a128a56ed406f572f9f06194e4b275", 243 | "sha256:733166464598c239323142c071fa4c9b91c14359176e5ae7e202db6bcc1d2eb5", 244 | "sha256:ad75fe10bea19ad2188c5cb5fc4cdf53ee808d9b44578c94a3cd1e9fc2beb656", 245 | "sha256:8966829cb0d21a08a3c5ac971a2eb67c3927ae27c247300a8476554cc0ce2ae8", 246 | "sha256:8bf51191d60f6987482ef0cfe8511bbf4877a5aa7f313d7b488b53189cf26209" 247 | ], 248 | "version": "==2.7.4" 249 | }, 250 | "py": { 251 | "hashes": [ 252 | "sha256:983f77f3331356039fdd792e9220b7b8ee1aa6bd2b25f567a963ff1de5a64f6a", 253 | "sha256:29c9fab495d7528e80ba1e343b958684f4ace687327e6f789a94bf3d1915f881" 254 | ], 255 | "version": "==1.5.3" 256 | }, 257 | "pygments": { 258 | "hashes": [ 259 | "sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d", 260 | "sha256:dbae1046def0efb574852fab9e90209b23f556367b5a320c0bcb871c77c3e8cc" 261 | ], 262 | "version": "==2.2.0" 263 | }, 264 | "pyparsing": { 265 | "hashes": [ 266 | "sha256:fee43f17a9c4087e7ed1605bd6df994c6173c1e977d7ade7b651292fab2bd010", 267 | "sha256:0832bcf47acd283788593e7a0f542407bd9550a55a8a8435214a1960e04bcb04", 268 | "sha256:9e8143a3e15c13713506886badd96ca4b579a87fbdf49e550dbfc057d6cb218e", 269 | "sha256:281683241b25fe9b80ec9d66017485f6deff1af5cde372469134b56ca8447a07", 270 | "sha256:b8b3117ed9bdf45e14dcc89345ce638ec7e0e29b2b579fa1ecf32ce45ebac8a5", 271 | "sha256:8f1e18d3fd36c6795bb7e02a39fd05c611ffc2596c1e0d995d34d67630426c18", 272 | "sha256:e4d45427c6e20a59bf4f88c639dcc03ce30d193112047f94012102f235853a58" 273 | ], 274 | "version": "==2.2.0" 275 | }, 276 | "python-dateutil": { 277 | "hashes": [ 278 | "sha256:3220490fb9741e2342e1cf29a503394fdac874bc39568288717ee67047ff29df", 279 | "sha256:9d8074be4c993fbe4947878ce593052f71dac82932a677d49194d8ce9778002e" 280 | ], 281 | "version": "==2.7.2" 282 | }, 283 | "pytz": { 284 | "hashes": [ 285 | "sha256:65ae0c8101309c45772196b21b74c46b2e5d11b6275c45d251b150d5da334555", 286 | "sha256:c06425302f2cf668f1bba7a0a03f3c1d34d4ebeef2c72003da308b3947c7f749" 287 | ], 288 | "version": "==2018.4" 289 | }, 290 | "pyyaml": { 291 | "hashes": [ 292 | "sha256:3262c96a1ca437e7e4763e2843746588a965426550f3797a79fca9c6199c431f", 293 | "sha256:16b20e970597e051997d90dc2cddc713a2876c47e3d92d59ee198700c5427736", 294 | "sha256:e863072cdf4c72eebf179342c94e6989c67185842d9997960b3e69290b2fa269", 295 | "sha256:bc6bced57f826ca7cb5125a10b23fd0f2fff3b7c4701d64c439a300ce665fff8", 296 | "sha256:c01b880ec30b5a6e6aa67b09a2fe3fb30473008c85cd6a67359a1b15ed6d83a4", 297 | "sha256:827dc04b8fa7d07c44de11fabbc888e627fa8293b695e0f99cb544fdfa1bf0d1", 298 | "sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab", 299 | "sha256:5f84523c076ad14ff5e6c037fe1c89a7f73a3e04cf0377cb4d017014976433f3", 300 | "sha256:0c507b7f74b3d2dd4d1322ec8a94794927305ab4cebbe89cc47fe5e81541e6e8", 301 | "sha256:b4c423ab23291d3945ac61346feeb9a0dc4184999ede5e7c43e1ffb975130ae6", 302 | "sha256:ca233c64c6e40eaa6c66ef97058cdc80e8d0157a443655baa1b2966e812807ca", 303 | "sha256:4474f8ea030b5127225b8894d626bb66c01cda098d47a2b0d3429b6700af9fd8", 304 | "sha256:326420cbb492172dec84b0f65c80942de6cedb5233c413dd824483989c000608", 305 | "sha256:5ac82e411044fb129bae5cfbeb3ba626acb2af31a8d17d175004b70862a741a7" 306 | ], 307 | "version": "==3.12" 308 | }, 309 | "requests": { 310 | "hashes": [ 311 | "sha256:6a1b267aa90cac58ac3a765d067950e7dbbf75b1da07e895d1f594193a40a38b", 312 | "sha256:9c443e7324ba5b85070c4a818ade28bfabedf16ea10206da1132edaa6dda237e" 313 | ], 314 | "version": "==2.18.4" 315 | }, 316 | "six": { 317 | "hashes": [ 318 | "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb", 319 | "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9" 320 | ], 321 | "version": "==1.11.0" 322 | }, 323 | "snowballstemmer": { 324 | "hashes": [ 325 | "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89", 326 | "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128" 327 | ], 328 | "version": "==1.2.1" 329 | }, 330 | "sphinx": { 331 | "hashes": [ 332 | "sha256:2e7ad92e96eff1b2006cf9f0cdb2743dacbae63755458594e9e8238b0c3dc60b", 333 | "sha256:e9b1a75a3eae05dded19c80eb17325be675e0698975baae976df603b6ed1eb10" 334 | ], 335 | "version": "==1.7.4" 336 | }, 337 | "sphinx-autobuild": { 338 | "hashes": [ 339 | "sha256:e60aea0789cab02fa32ee63c7acae5ef41c06f1434d9fd0a74250a61f5994692", 340 | "sha256:66388f81884666e3821edbe05dd53a0cfb68093873d17320d0610de8db28c74e" 341 | ], 342 | "version": "==0.7.1" 343 | }, 344 | "sphinxcontrib-websupport": { 345 | "hashes": [ 346 | "sha256:f4932e95869599b89bf4f80fc3989132d83c9faa5bf633e7b5e0c25dffb75da2", 347 | "sha256:7a85961326aa3a400cd4ad3c816d70ed6f7c740acd7ce5d78cd0a67825072eb9" 348 | ], 349 | "version": "==1.0.1" 350 | }, 351 | "sqlalchemy": { 352 | "hashes": [ 353 | "sha256:d6cda03b0187d6ed796ff70e87c9a7dce2c2c9650a7bc3c022cd331416853c31" 354 | ], 355 | "version": "==1.2.7" 356 | }, 357 | "tornado": { 358 | "hashes": [ 359 | "sha256:88ce0282cce70df9045e515f578c78f1ebc35dcabe1d70f800c3583ebda7f5f5", 360 | "sha256:ba9fbb249ac5390bff8a1d6aa4b844fd400701069bda7d2e380dfe2217895101", 361 | "sha256:408d129e9d13d3c55aa73f8084aa97d5f90ed84132e38d6932e63a67d5bec563", 362 | "sha256:c050089173c2e9272244bccfb6a8615fb9e53b79420a5551acfa76094ecc3111", 363 | "sha256:1b83d5c10550f2653380b4c77331d6f8850f287c4f67d7ce1e1c639d9222fbc7" 364 | ], 365 | "version": "==5.0.2" 366 | }, 367 | "tox": { 368 | "hashes": [ 369 | "sha256:9ee7de958a43806402a38c0d2aa07fa8553f4d2c20a15b140e9f771c2afeade0", 370 | "sha256:96efa09710a3daeeb845561ebbe1497641d9cef2ee0aea30db6969058b2bda2f" 371 | ], 372 | "version": "==3.0.0" 373 | }, 374 | "typing": { 375 | "hashes": [ 376 | "sha256:b2c689d54e1144bbcfd191b0832980a21c2dbcf7b5ff7a66248a60c90e951eb8", 377 | "sha256:3a887b021a77b292e151afb75323dea88a7bc1b3dfa92176cff8e44c8b68bddf", 378 | "sha256:d400a9344254803a2368533e4533a4200d21eb7b6b729c173bc38201a74db3f2" 379 | ], 380 | "version": "==3.6.4" 381 | }, 382 | "urllib3": { 383 | "hashes": [ 384 | "sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b", 385 | "sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f" 386 | ], 387 | "version": "==1.22" 388 | }, 389 | "virtualenv": { 390 | "hashes": [ 391 | "sha256:e8e05d4714a1c51a2f5921e62f547fcb0f713ebbe959e0a7f585cc8bef71d11f", 392 | "sha256:1d7e241b431e7afce47e77f8843a276f652699d1fa4f93b9d8ce0076fd7b0b54" 393 | ], 394 | "version": "==15.2.0" 395 | }, 396 | "watchdog": { 397 | "hashes": [ 398 | "sha256:7e65882adb7746039b6f3876ee174952f8eaaa34491ba34333ddf1fe35de4162" 399 | ], 400 | "version": "==0.8.3" 401 | }, 402 | "wheel": { 403 | "hashes": [ 404 | "sha256:9cdc8ab2cc9c3c2e2727a4b67c22881dbb0e1c503d592992594c5e131c867107", 405 | "sha256:1ae8153bed701cb062913b72429bcf854ba824f973735427681882a688cb55ce" 406 | ], 407 | "version": "==0.31.0" 408 | } 409 | } 410 | } 411 | --------------------------------------------------------------------------------