chainqueue

Blockchain transaction queue control
Log | Files | Refs | LICENSE

commit 6b044f18dbf18f75dd09ed354b1d60ff42b27c49
Author: nolash <dev@holbrook.no>
Date:   Fri,  2 Apr 2021 10:31:50 +0200

Initial commit

Diffstat:
A.gitignore | 2++
Achainqueue/db/__init__.py | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Achainqueue/db/migrations/default/README | 2++
Achainqueue/db/migrations/default/alembic.ini | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Achainqueue/db/migrations/default/env.py | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Achainqueue/db/migrations/default/gmon.out | 0
Achainqueue/db/migrations/default/script.py.mako | 24++++++++++++++++++++++++
Achainqueue/db/migrations/default/versions/2215c497248b_transaction_cache.py | 38++++++++++++++++++++++++++++++++++++++
Achainqueue/db/migrations/default/versions/3e43847c0717_otx_state_history.py | 30++++++++++++++++++++++++++++++
Achainqueue/db/migrations/default/versions/c537a0fd8466_outgoing_queue.py | 35+++++++++++++++++++++++++++++++++++
Achainqueue/db/models/base.py | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arequirements.txt | 4++++
Asetup.cfg | 33+++++++++++++++++++++++++++++++++
Asetup.py | 36++++++++++++++++++++++++++++++++++++
Atests/base.py | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/test_basic.py | 19+++++++++++++++++++
16 files changed, 646 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +*.pyc diff --git a/chainqueue/db/__init__.py b/chainqueue/db/__init__.py @@ -0,0 +1,57 @@ +# standard imports +import os +import logging + +# local imports +from chainqueue.db.models.base import SessionBase + +logg = logging.getLogger().getChild(__name__) + + +# an Engine, which the Session will use for connection +# resources + + +def dsn_from_config(config): + """Generate a dsn string from the provided config dict. + + The config dict must include all well-known database connection parameters, and must implement the method "get(key)" to retrieve them. Any missing parameters will be be rendered as the literal string "None" + + :param config: Configuration object + :type config: Varies + :returns: dsn string + :rtype: str + """ + scheme = config.get('DATABASE_ENGINE') + if config.get('DATABASE_DRIVER') != None: + scheme += '+{}'.format(config.get('DATABASE_DRIVER')) + + dsn = '' + dsn_out = '' + if config.get('DATABASE_ENGINE') == 'sqlite': + dsn = '{}:///{}'.format( + scheme, + config.get('DATABASE_NAME'), + ) + dsn_out = dsn + + else: + dsn = '{}://{}:{}@{}:{}/{}'.format( + scheme, + config.get('DATABASE_USER'), + config.get('DATABASE_PASSWORD'), + config.get('DATABASE_HOST'), + config.get('DATABASE_PORT'), + config.get('DATABASE_NAME'), + ) + dsn_out = '{}://{}:{}@{}:{}/{}'.format( + scheme, + config.get('DATABASE_USER'), + '***', + config.get('DATABASE_HOST'), + config.get('DATABASE_PORT'), + config.get('DATABASE_NAME'), + ) + logg.debug('parsed dsn from config: {}'.format(dsn_out)) + return dsn + diff --git a/chainqueue/db/migrations/default/README b/chainqueue/db/migrations/default/README @@ -0,0 +1 @@ +Generic single-database configuration. +\ No newline at end of file diff --git a/chainqueue/db/migrations/default/alembic.ini b/chainqueue/db/migrations/default/alembic.ini @@ -0,0 +1,85 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = . + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to ./versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat ./versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks=black +# black.type=console_scripts +# black.entrypoint=black +# black.options=-l 79 + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/chainqueue/db/migrations/default/env.py b/chainqueue/db/migrations/default/env.py @@ -0,0 +1,77 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/chainqueue/db/migrations/default/gmon.out b/chainqueue/db/migrations/default/gmon.out Binary files differ. diff --git a/chainqueue/db/migrations/default/script.py.mako b/chainqueue/db/migrations/default/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/chainqueue/db/migrations/default/versions/2215c497248b_transaction_cache.py b/chainqueue/db/migrations/default/versions/2215c497248b_transaction_cache.py @@ -0,0 +1,38 @@ +"""Transaction cache + +Revision ID: 2215c497248b +Revises: c537a0fd8466 +Create Date: 2021-04-02 10:09:11.923949 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2215c497248b' +down_revision = 'c537a0fd8466' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'tx_cache', + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('otx_id', sa.Integer, sa.ForeignKey('otx.id'), nullable=True), + sa.Column('date_created', sa.DateTime, nullable=False), + sa.Column('date_updated', sa.DateTime, nullable=False), + sa.Column('source_token_address', sa.String(42), nullable=False), + sa.Column('destination_token_address', sa.String(42), nullable=False), + sa.Column('sender', sa.String(42), nullable=False), + sa.Column('recipient', sa.String(42), nullable=False), + sa.Column('from_value', sa.NUMERIC(), nullable=False), + sa.Column('to_value', sa.NUMERIC(), nullable=True), + sa.Column('block_number', sa.BIGINT(), nullable=True), + sa.Column('tx_index', sa.Integer, nullable=True), + ) + + +def downgrade(): + op.drop_table('tx_cache') diff --git a/chainqueue/db/migrations/default/versions/3e43847c0717_otx_state_history.py b/chainqueue/db/migrations/default/versions/3e43847c0717_otx_state_history.py @@ -0,0 +1,30 @@ +"""Otx state history + +Revision ID: 3e43847c0717 +Revises: 2215c497248b +Create Date: 2021-04-02 10:10:58.656139 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3e43847c0717' +down_revision = '2215c497248b' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'otx_state_log', + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('otx_id', sa.Integer, sa.ForeignKey('otx.id'), nullable=False), + sa.Column('date', sa.DateTime, nullable=False), + sa.Column('status', sa.Integer, nullable=False), + ) + + +def downgrade(): + op.drop_table('otx_state_log') diff --git a/chainqueue/db/migrations/default/versions/c537a0fd8466_outgoing_queue.py b/chainqueue/db/migrations/default/versions/c537a0fd8466_outgoing_queue.py @@ -0,0 +1,35 @@ +"""Outgoing queue + +Revision ID: c537a0fd8466 +Revises: +Create Date: 2021-04-02 10:04:27.092819 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c537a0fd8466' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'otx', + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('date_created', sa.DateTime, nullable=False), + sa.Column('nonce', sa.Integer, nullable=False), + sa.Column('tx_hash', sa.Text, nullable=False), + sa.Column('signed_tx', sa.Text, nullable=False), + sa.Column('status', sa.Integer, nullable=False, default=0), + sa.Column('block', sa.Integer), + ) + op.create_index('idx_otx_tx', 'otx', ['tx_hash'], unique=True) + + +def downgrade(): + op.drop_index('idx_otx_tx') + op.drop_table('otx') diff --git a/chainqueue/db/models/base.py b/chainqueue/db/models/base.py @@ -0,0 +1,121 @@ +# stanard imports +import logging + +# third-party imports +from sqlalchemy import Column, Integer +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import ( + StaticPool, + QueuePool, + AssertionPool, + ) + +logg = logging.getLogger() + +Model = declarative_base(name='Model') + + +class SessionBase(Model): + """The base object for all SQLAlchemy enabled models. All other models must extend this. + """ + __abstract__ = True + + id = Column(Integer, primary_key=True) + + engine = None + """Database connection engine of the running aplication""" + sessionmaker = None + """Factory object responsible for creating sessions from the connection pool""" + transactional = True + """Whether the database backend supports query transactions. Should be explicitly set by initialization code""" + poolable = True + """Whether the database backend supports connection pools. Should be explicitly set by initialization code""" + procedural = True + """Whether the database backend supports stored procedures""" + localsessions = {} + """Contains dictionary of sessions initiated by db model components""" + + + @staticmethod + def create_session(): + """Creates a new database session. + """ + return SessionBase.sessionmaker() + + + @staticmethod + def _set_engine(engine): + """Sets the database engine static property + """ + SessionBase.engine = engine + SessionBase.sessionmaker = sessionmaker(bind=SessionBase.engine) + + + @staticmethod + def connect(dsn, pool_size=16, debug=False): + """Create new database connection engine and connect to database backend. + + :param dsn: DSN string defining connection. + :type dsn: str + """ + e = None + if SessionBase.poolable: + poolclass = QueuePool + if pool_size > 1: + e = create_engine( + dsn, + max_overflow=pool_size*3, + pool_pre_ping=True, + pool_size=pool_size, + pool_recycle=60, + poolclass=poolclass, + echo=debug, + ) + else: + if debug: + poolclass = AssertionPool + else: + poolclass = StaticPool + + e = create_engine( + dsn, + poolclass=poolclass, + echo=debug, + ) + else: + e = create_engine( + dsn, + echo=debug, + ) + + SessionBase._set_engine(e) + + + @staticmethod + def disconnect(): + """Disconnect from database and free resources. + """ + SessionBase.engine.dispose() + SessionBase.engine = None + + + @staticmethod + def bind_session(session=None): + localsession = session + if localsession == None: + localsession = SessionBase.create_session() + localsession_key = str(id(localsession)) + logg.debug('creating new session {}'.format(localsession_key)) + SessionBase.localsessions[localsession_key] = localsession + return localsession + + + @staticmethod + def release_session(session=None): + session_key = str(id(session)) + if SessionBase.localsessions.get(session_key) != None: + logg.debug('commit and destroy session {}'.format(session_key)) + session.commit() + session.close() diff --git a/requirements.txt b/requirements.txt @@ -0,0 +1,4 @@ +pysha3==1.0.2 +hexathon~=0.0.1a7 +alembic==1.4.2 +SQLAlchemy==1.3.20 diff --git a/setup.cfg b/setup.cfg @@ -0,0 +1,33 @@ +[metadata] +name = chainqueue +version = 0.0.1a1 +description = Generic blockchain transaction queue control +author = Louis Holbrook +author_email = dev@holbrook.no +url = https://gitlab.com/nolash/chainlib +keywords = + cic + cryptocurrency + ethereum + solidarity + mutual_credit +classifiers = + Programming Language :: Python :: 3 + Operating System :: OS Independent + Development Status :: 3 - Alpha + Environment :: Console + Intended Audience :: Developers + License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) + Topic :: Internet +# Topic :: Blockchain :: EVM +license = GPL3 +licence_files = + LICENSE.txt + +[options] +python_requires = >= 3.6 +packages = + chainqueue + +#[options.entry_points] +#console_scripts = diff --git a/setup.py b/setup.py @@ -0,0 +1,36 @@ +from setuptools import setup +import configparser +import os + + +requirements = [] +f = open('requirements.txt', 'r') +while True: + l = f.readline() + if l == '': + break + requirements.append(l.rstrip()) +f.close() + +test_requirements = [] +f = open('test_requirements.txt', 'r') +while True: + l = f.readline() + if l == '': + break + test_requirements.append(l.rstrip()) +f.close() + +postgres_requirements = [ + 'psycopg2==2.8.6', + ] + requirements +sqlite_requirements = [ + ] + requirements +setup( + install_requires=requirements, + tests_require=test_requirements, + extras_require={ + 'postgres': postgres_requirements, + 'sqlite': sqlite_requirements, + } + ) diff --git a/tests/base.py b/tests/base.py @@ -0,0 +1,83 @@ +# standard imports +import logging +import unittest +import tempfile +import os +#import pysqlite + +# external imports +from chainlib.chain import ChainSpec +import alembic +import alembic.config + +# local imports +from chainqueue.db import dsn_from_config +from chainqueue.db.models.base import SessionBase + +script_dir = os.path.realpath(os.path.dirname(__file__)) + +logg = logging.getLogger().getChild(__name__) + + +class TestBase(unittest.TestCase): + + def setUp(self): + rootdir = os.path.dirname(os.path.dirname(__file__)) + dbdir = os.path.join(rootdir, 'chainqueue', 'db') + #migrationsdir = os.path.join(dbdir, 'migrations', load_config.get('DATABASE_ENGINE')) + #if not os.path.isdir(migrationsdir): + migrationsdir = os.path.join(dbdir, 'migrations', 'default') + logg.info('using migrations directory {}'.format(migrationsdir)) + +# db_dir = tempfile.mkdtemp() +# self.db_path = os.path.join(db_dir, 'test.sqlite') +# config = { +# 'DATABASE_ENGINE': 'sqlite', +# 'DATABASE_DRIVER': 'pysqlite', +# 'DATABASE_NAME': self.db_path, +# } + + config = { + 'DATABASE_ENGINE': 'sqlite', + 'DATABASE_DRIVER': 'pysqlite', + 'DATABASE_NAME': 'chainqueue.sqlite', + } + logg.debug('config {}'.format(config)) + + dsn = dsn_from_config(config) + SessionBase.poolable = False + SessionBase.transactional = False + SessionBase.procedural = False + SessionBase.connect(dsn, debug=False) + + ac = alembic.config.Config(os.path.join(migrationsdir, 'alembic.ini')) + ac.set_main_option('sqlalchemy.url', dsn) + ac.set_main_option('script_location', migrationsdir) + + alembic.command.downgrade(ac, 'base') + alembic.command.upgrade(ac, 'head') + + + + self.session = SessionBase.create_session() +# +# f = open(os.path.join(script_dir, '..', 'sql', 'sqlite', '1.sql'), 'r') +# sql = f.read() +# f.close() +# +# conn = SessionBase.engine.connect() +# conn.execute(sql) +# +# f = open(os.path.join(script_dir, '..', 'sql', 'sqlite', '2.sql'), 'r') +# sql = f.read() +# f.close() +# +# conn = SessionBase.engine.connect() +# conn.execute(sql) +# + self.chain_spec = ChainSpec('evm', 'foo', 42, 'bar') + + + def tearDown(self): + self.session.commit() + self.session.close() diff --git a/tests/test_basic.py b/tests/test_basic.py @@ -0,0 +1,19 @@ +# standard imports +import logging +import unittest + +# test imports +from tests.base import TestBase + +logging.basicConfig(level=logging.DEBUG) +logg = logging.getLogger() + +class TestBasic(TestBase): + + def test_hello(self): + logg.debug('foo') + pass + + +if __name__ == '__main__': + unittest.main()