initial import
This commit is contained in:
commit
02a56bf94d
16 changed files with 536 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info
|
||||
*.project
|
||||
*.swp
|
||||
*.pydevproject
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
*.ropeproject
|
||||
*#*#
|
||||
*.#*
|
||||
__pycache__
|
7
CHANGES.txt
Normal file
7
CHANGES.txt
Normal file
|
@ -0,0 +1,7 @@
|
|||
Changelog
|
||||
=========
|
||||
|
||||
3.0.1 (unreleased)
|
||||
------------------
|
||||
|
||||
- Package created from cco.storage (2024-02-14)
|
1
CONTRIBUTORS.txt
Normal file
1
CONTRIBUTORS.txt
Normal file
|
@ -0,0 +1 @@
|
|||
- Helmut Merz, Original Author
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (C) 2024 cyberconcepts.org team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
17
README.md
Normal file
17
README.md
Normal file
|
@ -0,0 +1,17 @@
|
|||
# py-scopes
|
||||
|
||||
The 'py-scopes' package is a re-implementation of a similar module in Go,
|
||||
called 'go-scopes'. It processes application data focussing on changes
|
||||
instead of objects or state.
|
||||
|
||||
The first sub-package (scopes.storage) deals with storing application data
|
||||
(as records or tracks, messages, or more specific kinds of entities)
|
||||
in a SQL database, using some header columns for indexing and direct access and
|
||||
a jsonb column for the real data (payload).
|
||||
|
||||
Status: implementation started
|
||||
|
||||
Project website: https://www.cyberconcepts.org
|
||||
|
||||
License: MIT, see LICENSE file
|
||||
|
21
docs/LICENSE.txt
Normal file
21
docs/LICENSE.txt
Normal file
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (C) 2017 cyberconcepts.org team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
22
pyproject.toml
Normal file
22
pyproject.toml
Normal file
|
@ -0,0 +1,22 @@
|
|||
[build-system]
|
||||
requires = ["setuptools"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "py-scopes"
|
||||
version = "3.0.1"
|
||||
description = "Implementation of the strange 'scopes' paradigma in Python"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
keywords = ["scopes"]
|
||||
authors = [{name = "Helmut Merz", email = "helmutm@cy55.de"}]
|
||||
|
||||
dependencies = [
|
||||
"transaction",
|
||||
"psycopg[binary]",
|
||||
"SQLAlchemy",
|
||||
"zope.sqlalchemy",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest"]
|
6
requirements.txt
Normal file
6
requirements.txt
Normal file
|
@ -0,0 +1,6 @@
|
|||
setuptools
|
||||
psycopg[binary]
|
||||
SQLAlchemy
|
||||
transaction
|
||||
zope.sqlalchemy
|
||||
|
1
scopes/__init__.py
Normal file
1
scopes/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""package scopes"""
|
37
scopes/storage/README.rst
Normal file
37
scopes/storage/README.rst
Normal file
|
@ -0,0 +1,37 @@
|
|||
========================================================
|
||||
SQL-based Storage for Records (Tracks) and Other Objects
|
||||
========================================================
|
||||
|
||||
Test Prerequisite: PostgreSQL database ccotest (user ccotest with password cco).
|
||||
|
||||
>>> from cco.storage.common import getEngine, sessionFactory
|
||||
>>> from cco.storage.tracking import record
|
||||
|
||||
>>> record.engine = getEngine('postgresql+psycopg', 'ccotest', 'ccotest', 'cco')
|
||||
>>> record.Session = sessionFactory(record.engine)
|
||||
|
||||
|
||||
Tracking Storage
|
||||
================
|
||||
|
||||
>>> storage = record.Storage(doCommit=True)
|
||||
|
||||
>>> tr01 = record.Track('t01', 'john')
|
||||
>>> tr01.head
|
||||
{'taskId': 't01', 'userName': 'john'}
|
||||
|
||||
>>> storage.getTable()
|
||||
Table(...)
|
||||
|
||||
>>> trackId = storage.save(tr01)
|
||||
>>> trackId > 0
|
||||
True
|
||||
|
||||
>>> tr01a = storage.get(trackId)
|
||||
>>> tr01a.head
|
||||
|
||||
Fin
|
||||
===
|
||||
|
||||
>>> storage.conn.close()
|
||||
|
1
scopes/storage/__init__.py
Normal file
1
scopes/storage/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""package scopes.storage"""
|
79
scopes/storage/common.py
Normal file
79
scopes/storage/common.py
Normal file
|
@ -0,0 +1,79 @@
|
|||
# scopes.storage.common
|
||||
|
||||
"""Common utility stuff for the scopes.storage package."""
|
||||
|
||||
from sqlalchemy import create_engine, MetaData, text
|
||||
from sqlalchemy.orm import scoped_session, sessionmaker
|
||||
import threading
|
||||
import zope.sqlalchemy
|
||||
|
||||
|
||||
def getEngine(dbtype, dbname, user, pw, host='localhost', port=5432, **kw):
|
||||
return create_engine('%s://%s:%s@%s:%s/%s' % (
|
||||
dbtype, user, pw, host, port, dbname), **kw)
|
||||
|
||||
def sessionFactory(engine):
|
||||
Session = scoped_session(sessionmaker(bind=engine, twophase=True))
|
||||
zope.sqlalchemy.register(Session)
|
||||
return Session
|
||||
|
||||
# put something like this in code before first creating a Storage object
|
||||
#engine = getEngine('postgresql+psycopg', 'testdb', 'testuser', 'secret')
|
||||
#scopes.storage.common.engine = engine
|
||||
#scopes.storage.common.Session = sessionFactory(engine)
|
||||
|
||||
|
||||
class Storage(object):
|
||||
|
||||
def __init__(self, schema=None):
|
||||
self.engine = engine
|
||||
self.session = Session()
|
||||
self.schema = schema
|
||||
self.metadata = MetaData(schema=schema)
|
||||
self.containers = {}
|
||||
|
||||
def create(self, cls):
|
||||
container = cls(self)
|
||||
self.add(container)
|
||||
return container
|
||||
|
||||
def add(self, container):
|
||||
self.containers[container.itemFactory.prefix] = container
|
||||
|
||||
def getItem(self, uid):
|
||||
prefix, id = uid.split('-')
|
||||
id = int(id)
|
||||
container = self.containers.get(prefix)
|
||||
if container is None:
|
||||
container = self.create(registry[prefix])
|
||||
return container.get(id)
|
||||
|
||||
def getExistingTable(self, tableName):
|
||||
metadata = self.metadata
|
||||
schema = self.schema
|
||||
metadata.reflect(self.engine)
|
||||
return metadata.tables.get((schema and schema + '.' or '') + tableName)
|
||||
|
||||
def dropTable(self, tableName):
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(text('drop table if exists %s.%s' % (self.schema, tableName)))
|
||||
|
||||
def resetSequence(self, tableName, colName, v):
|
||||
sq = ('alter sequence %s.%s_%s_seq restart %i' %
|
||||
(self.schema, tableName, colName, v))
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(text(sq))
|
||||
|
||||
|
||||
# store information about container implementations, identified by a uid prefix.
|
||||
|
||||
registry = {}
|
||||
|
||||
def registerContainerClass(cls):
|
||||
# TODO: error on duplicate key
|
||||
registry[cls.itemFactory.prefix] = cls
|
||||
cls.headCols = cols = tuple(f.lower() for f in cls.itemFactory.headFields)
|
||||
if cls.indexes is None:
|
||||
cls.indexes = [cols[i:] for i in range(len(cols))]
|
||||
return cls
|
||||
|
50
scopes/storage/proxy.py
Normal file
50
scopes/storage/proxy.py
Normal file
|
@ -0,0 +1,50 @@
|
|||
# scopes.storage.proxy
|
||||
|
||||
"""Core classes and helper functions for creating proxy and adapter objects
|
||||
in order to store attribute values in a SQL database.
|
||||
|
||||
This is currently in concept and exploration state.
|
||||
"""
|
||||
|
||||
import transaction
|
||||
|
||||
_not_found = object()
|
||||
|
||||
|
||||
def loadData(obj):
|
||||
print ('getData ***', obj.context.__name__, obj.context.__parent__.__name__)
|
||||
return dict(dummy='dummy')
|
||||
|
||||
|
||||
def storeData(obj, data):
|
||||
print ('storeData ***', obj.context.__name__, obj.context.__parent__.__name__, data)
|
||||
|
||||
|
||||
class AdapterBase(object):
|
||||
|
||||
_old_data = None
|
||||
_cont = None
|
||||
_id = None
|
||||
|
||||
def __init__(self, context):
|
||||
super(AdapterBase, self).__init__(context)
|
||||
object.__setattr__(self, '_new_data', {})
|
||||
|
||||
def __getattr__(self, attr):
|
||||
value = self._new_data.get(attr, _not_found)
|
||||
if value is _not_found:
|
||||
if self._old_data is None:
|
||||
object.__setattr__(self, '_old_data', loadData(self))
|
||||
value = self._old_data.get(attr, _not_found)
|
||||
if value is _not_found:
|
||||
return super(AdapterBase, self).__getattr__(attr)
|
||||
return value
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
super(AdapterBase, self).__setattr__(attr, value)
|
||||
if attr.startswith('__') or attr in self._adapterAttributes:
|
||||
return
|
||||
if not self._new_data:
|
||||
tr = transaction.manager.get()
|
||||
tr.addBeforeCommitHook(storeData, [self, self._new_data], {})
|
||||
self._new_data[attr] = value
|
178
scopes/storage/tracking.py
Normal file
178
scopes/storage/tracking.py
Normal file
|
@ -0,0 +1,178 @@
|
|||
# scopes.storage.tracking
|
||||
|
||||
"""SQL-based storage for simple tracks (records).
|
||||
|
||||
A track consists of a head (index data, metadata) with a fixed set of fields and
|
||||
data (payload) represented as a dict.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Table, Column, Index
|
||||
from sqlalchemy import BigInteger, DateTime, Text, func
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
import transaction
|
||||
from zope.sqlalchemy import register, mark_changed
|
||||
|
||||
from scopes.storage.common import registerContainerClass
|
||||
|
||||
|
||||
class Track(object):
|
||||
|
||||
headFields = ['taskId', 'userName']
|
||||
prefix = 'rec'
|
||||
|
||||
def __init__(self, *keys, data=None, timeStamp=None, trackId=None, container=None):
|
||||
self.head = {}
|
||||
for ix, k in enumerate(keys):
|
||||
self.head[self.headFields[ix]] = k
|
||||
for k in self.headFields:
|
||||
if self.head.get(k) is None:
|
||||
self.heaad[k] = ''
|
||||
setattr(self, k, self.head[k])
|
||||
self.data = data or {}
|
||||
self.timeStamp = timeStamp
|
||||
self.trackId = trackId
|
||||
self.container = container
|
||||
|
||||
def update(self, data, overwrite=False):
|
||||
if data is None:
|
||||
return
|
||||
if overwrite:
|
||||
self.data = data
|
||||
else:
|
||||
self.data.update(data)
|
||||
|
||||
@property
|
||||
def uid(self):
|
||||
if self.trackId is None:
|
||||
return None
|
||||
return '%s-%d' % (self.prefix, self.trackId)
|
||||
|
||||
|
||||
@registerContainerClass
|
||||
class Container(object):
|
||||
|
||||
itemFactory = Track
|
||||
tableName = 'tracks'
|
||||
insertOnChange = True # always insert new track when data are changed
|
||||
indexes = None # default, will be overwritten by registerContainerClass()
|
||||
#indexes = [('username',), ('taskid', 'username')] # or put explicitly in class
|
||||
|
||||
table = None
|
||||
|
||||
def __init__(self, storage):
|
||||
self.storage = storage
|
||||
self.session = storage.session
|
||||
self.table = self.getTable()
|
||||
|
||||
def get(self, trackId):
|
||||
stmt = self.table.select().where(self.table.c.trackid == trackId)
|
||||
return self.makeTrack(self.session.execute(stmt).first())
|
||||
|
||||
def query(self, **crit):
|
||||
stmt = self.table.select().where(
|
||||
and_(*self.setupWhere(crit))).order_by(self.table.c.trackid)
|
||||
for r in self.session.execute(stmt):
|
||||
yield self.makeTrack(r)
|
||||
|
||||
def queryLast(self, **crit):
|
||||
stmt = (self.table.select().where(and_(*self.setupWhere(crit))).
|
||||
order_by(self.table.c.trackid.desc()).limit(1))
|
||||
return self.makeTrack(self.session.execute(stmt).first())
|
||||
|
||||
def save(self, track):
|
||||
crit = dict((hf, track.head[hf]) for hf in track.headFields)
|
||||
found = self.queryLast(**crit)
|
||||
if found is None:
|
||||
return self.insert(track)
|
||||
if self.insertOnChange and found.data != track.data:
|
||||
return self.insert(track)
|
||||
if found.data != track.data or found.timeStamp != track.timeStamp:
|
||||
found.update(track.data)
|
||||
found.timeStamp = track.timeStamp
|
||||
self.update(found)
|
||||
return found.trackId
|
||||
|
||||
def insert(self, track, withTrackId=False):
|
||||
t = self.table
|
||||
values = self.setupValues(track, withTrackId)
|
||||
stmt = t.insert().values(**values).returning(t.c.trackid)
|
||||
trackId = self.session.execute(stmt).first()[0]
|
||||
mark_changed(self.session)
|
||||
return trackId
|
||||
|
||||
def update(self, track):
|
||||
t = self.table
|
||||
values = self.setupValues(track)
|
||||
if track.timeStamp is None:
|
||||
values['timestamp'] = datetime.now()
|
||||
stmt = t.update().values(**values).where(t.c.trackid == track.trackId)
|
||||
n = self.session.execute(stmt).rowcount
|
||||
if n > 0:
|
||||
mark_changed(self.session)
|
||||
return n
|
||||
|
||||
def upsert(self, track):
|
||||
"""Try to update the record identified by the trackId given with ``track``.
|
||||
If not found insert new record without generating a new trackId.
|
||||
Use this method for migration and other bulk insert/update tasks.
|
||||
Don't forget to update the trackid sequence afterwards:
|
||||
``select setval('<schema>.tracks_trackid_seq', <max>);``"""
|
||||
if track.trackId is not None:
|
||||
if self.update(track) > 0:
|
||||
return track.trackId
|
||||
return self.insert(track, withTrackId=True)
|
||||
|
||||
def remove(self, trackId):
|
||||
stmt = self.table.delete().where(self.table.c.trackid == trackId)
|
||||
n = self.session.execute(stmt).rowcount
|
||||
if n > 0:
|
||||
mark_changed(self.session)
|
||||
return n
|
||||
|
||||
def makeTrack(self, r):
|
||||
if r is None:
|
||||
return None
|
||||
return self.itemFactory(
|
||||
*r[1:-2], trackId=r[0],timeStamp=r[-2], data=r[-1], container=self)
|
||||
|
||||
def setupWhere(self, crit):
|
||||
return [self.table.c[k.lower()] == v for k, v in crit.items()]
|
||||
|
||||
def setupValues(self, track, withTrackId=False):
|
||||
values = {}
|
||||
hf = self.itemFactory.headFields
|
||||
for i, c in enumerate(self.headCols):
|
||||
values[c] = track.head[hf[i]]
|
||||
values['data'] = track.data
|
||||
if track.timeStamp is not None:
|
||||
values['timestamp'] = track.timeStamp
|
||||
if withTrackId and track.trackId is not None:
|
||||
values['trackid'] = track.trackId
|
||||
return values
|
||||
|
||||
def getTable(self):
|
||||
#table = self.storage.getExistingTable(self.tableName)
|
||||
#if table is None:
|
||||
return createTable(self.storage, self.tableName, self.headCols,
|
||||
indexes=self.indexes)
|
||||
|
||||
|
||||
def createTable(storage, tableName, headcols, indexes=None):
|
||||
metadata = storage.metadata
|
||||
cols = [Column('trackid', BigInteger, primary_key=True)]
|
||||
idxs = []
|
||||
for ix, f in enumerate(headcols):
|
||||
cols.append(Column(f.lower(), Text, nullable=False, server_default=''))
|
||||
cols.append(Column('timestamp', DateTime(timezone=True),
|
||||
nullable=False, server_default=func.now()))
|
||||
for ix, idef in enumerate(indexes):
|
||||
indexName = 'idx_%s_%d' % (tableName, (ix + 1))
|
||||
idxs.append(Index(indexName, *idef))
|
||||
idxs.append(Index('idx_%s_ts' % tableName, 'timestamp'))
|
||||
cols.append(Column('data', JSONB, nullable=False, server_default='{}'))
|
||||
table = Table(tableName, metadata, *(cols+idxs), extend_existing=True)
|
||||
metadata.create_all(storage.engine)
|
||||
return table
|
||||
|
4
setup.py
Normal file
4
setup.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
from setuptools import setup
|
||||
|
||||
setup()
|
||||
|
79
tests/test_storage.py
Normal file
79
tests/test_storage.py
Normal file
|
@ -0,0 +1,79 @@
|
|||
#! /usr/bin/python
|
||||
|
||||
"""Tests for the 'scopes.storage' package."""
|
||||
|
||||
from datetime import datetime
|
||||
import transaction
|
||||
import unittest
|
||||
|
||||
import scopes.storage.common
|
||||
from scopes.storage.common import Storage, getEngine, sessionFactory
|
||||
from scopes.storage import proxy
|
||||
from scopes.storage import tracking
|
||||
|
||||
engine = getEngine('postgresql+psycopg', 'testdb', 'testuser', 'secret')
|
||||
scopes.storage.common.engine = engine
|
||||
scopes.storage.common.Session = sessionFactory(engine)
|
||||
|
||||
storage = Storage(schema='testing')
|
||||
|
||||
|
||||
class Test(unittest.TestCase):
|
||||
"Basic tests for the cco.storage package."
|
||||
|
||||
def testBasicStuff(self):
|
||||
storage.dropTable('tracks')
|
||||
tracks = storage.create(tracking.Container)
|
||||
|
||||
tr01 = tracking.Track('t01', 'john')
|
||||
tr01.update(dict(activity='testing'))
|
||||
self.assertEqual(tr01.head, {'taskId': 't01', 'userName': 'john'})
|
||||
self.assertEqual(tr01.taskId, 't01')
|
||||
self.assertEqual(tr01.userName, 'john')
|
||||
|
||||
self.assertTrue(tracks.getTable() is not None)
|
||||
|
||||
trid01 = tracks.save(tr01)
|
||||
self.assertTrue(trid01 > 0)
|
||||
|
||||
tr01a = tracks.get(trid01)
|
||||
self.assertEqual(tr01a.head, tr01.head)
|
||||
self.assertEqual(tr01a.trackId, trid01)
|
||||
self.assertEqual(tr01a.data.get('activity'), 'testing')
|
||||
|
||||
tr01a.update(dict(text='Set up unit tests.'))
|
||||
tr01a.timeStamp = None
|
||||
self.assertTrue(tracks.save(tr01a) > 0)
|
||||
|
||||
tr01b = tracks.queryLast(taskId='t01')
|
||||
self.assertEqual(tr01b.head, tr01.head)
|
||||
self.assertNotEqual(tr01b.trackId, trid01)
|
||||
self.assertEqual(tr01b.data.get('activity'), 'testing')
|
||||
|
||||
tr02 = tracking.Track('t02', 'jim', trackId=31, timeStamp=datetime(2023, 11, 30),
|
||||
data=dict(activity='concept'))
|
||||
trid02 = tracks.upsert(tr02)
|
||||
self.assertEqual(trid02, 31)
|
||||
self.assertEqual(tr02.uid, 'rec-31')
|
||||
tr02.trackId = trid01
|
||||
trid021 = tracks.upsert(tr02)
|
||||
self.assertEqual(trid021, trid01)
|
||||
self.assertEqual(tr02.uid, 'rec-' + str(trid01))
|
||||
|
||||
tr03 = storage.getItem('rec-31')
|
||||
self.assertEqual(tr03.trackId, 31)
|
||||
|
||||
n = tracks.remove(31)
|
||||
self.assertEqual(n, 1)
|
||||
self.assertEqual(tracks.get(31), None)
|
||||
|
||||
transaction.commit()
|
||||
|
||||
|
||||
def suite():
|
||||
return unittest.TestSuite((
|
||||
unittest.TestLoader().loadTestsFromTestCase(Test),
|
||||
))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(defaultTest='suite')
|
Loading…
Add table
Reference in a new issue