underscorepy 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- _/__init__.py +46 -0
- _/application.py +278 -0
- _/auth.py +163 -0
- _/caches/__init__.py +50 -0
- _/caches/dbcache.py +140 -0
- _/caches/memory.py +77 -0
- _/caches/redis.py +104 -0
- _/components.py +107 -0
- _/databases/__init__.py +282 -0
- _/databases/postgres.py +38 -0
- _/databases/sqlite.py +78 -0
- _/handlers.py +45 -0
- _/logins/__init__.py +82 -0
- _/logins/dblogin.py +230 -0
- _/logins/facebook.py +40 -0
- _/logins/gitlab.py +58 -0
- _/logins/google.py +20 -0
- _/logins/htpasswd.py +47 -0
- _/logins/oauth2.py +52 -0
- _/logins/slap.py +56 -0
- _/records/Protobuf.proto +21 -0
- _/records/__init__.py +186 -0
- _/records/data.py +232 -0
- _/records/protobuf.py +496 -0
- _/settings.py +159 -0
- _/supports/__init__.py +28 -0
- _/supports/nginx/__init__.py +78 -0
- _/supports/nginx/nginx.conf +59 -0
- _/supports/systemd/__init__.py +60 -0
- _/supports/systemd/systemd.service +12 -0
- _/test.py +20 -0
- _/utils.py +85 -0
- _/version.py +5 -0
- _/websockets.py +57 -0
- underscorepy-0.9.0.dist-info/METADATA +85 -0
- underscorepy-0.9.0.dist-info/RECORD +39 -0
- underscorepy-0.9.0.dist-info/WHEEL +5 -0
- underscorepy-0.9.0.dist-info/licenses/LICENSE +22 -0
- underscorepy-0.9.0.dist-info/top_level.txt +1 -0
_/caches/memory.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#
|
|
2
|
+
# (c) 2015-2023 Matthew Shaw
|
|
3
|
+
#
|
|
4
|
+
# Authors
|
|
5
|
+
# =======
|
|
6
|
+
# Matthew Shaw <mshaw.cx@gmail.com>
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
import _
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Memory(_.caches.Cache):
|
|
16
|
+
async def init(self, component_name, **kwds):
|
|
17
|
+
if not hasattr(_.application, 'is_session_expired'):
|
|
18
|
+
raise _.error('Application does not have is_session_expired function defined')
|
|
19
|
+
|
|
20
|
+
self.sessions = {}
|
|
21
|
+
|
|
22
|
+
members = dict(
|
|
23
|
+
component = component_name,
|
|
24
|
+
sessions = self.sessions,
|
|
25
|
+
)
|
|
26
|
+
subclass = type(component_name, (MemorySessions,), _.prefix(members))
|
|
27
|
+
_.application._record_handler('sessions', subclass)
|
|
28
|
+
|
|
29
|
+
def cookie_secret(self):
|
|
30
|
+
return os.urandom(32)
|
|
31
|
+
|
|
32
|
+
def save_session(self, session):
|
|
33
|
+
try:
|
|
34
|
+
session_id = session['session_id']
|
|
35
|
+
except KeyError:
|
|
36
|
+
raise _.error('No session_id defined in session')
|
|
37
|
+
self.sessions[session_id] = json.dumps(session)
|
|
38
|
+
|
|
39
|
+
async def load_session(self, session_id):
|
|
40
|
+
session = self.sessions.get(session_id, None)
|
|
41
|
+
if not session:
|
|
42
|
+
return None
|
|
43
|
+
session = json.loads(session)
|
|
44
|
+
if await _.wait(_.application.is_session_expired(session, self._expires)):
|
|
45
|
+
return None
|
|
46
|
+
return session
|
|
47
|
+
|
|
48
|
+
def clear_session(self, session_id):
|
|
49
|
+
self.sessions.pop(session_id, None)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class MemorySessions(_.handlers.Protected):
|
|
53
|
+
@_.auth.protected
|
|
54
|
+
async def get(self, session_id=None):
|
|
55
|
+
if session_id:
|
|
56
|
+
session = self._sessions[session_id]
|
|
57
|
+
if not session:
|
|
58
|
+
raise _.HTTPError(404)
|
|
59
|
+
session = json.loads(session)
|
|
60
|
+
self.write(session)
|
|
61
|
+
else:
|
|
62
|
+
data = []
|
|
63
|
+
for session_id in self._sessions:
|
|
64
|
+
session = self._sessions[session_id]
|
|
65
|
+
data.append(json.loads(session))
|
|
66
|
+
self.write({'data':data})
|
|
67
|
+
|
|
68
|
+
@_.auth.protected
|
|
69
|
+
async def delete(self, session_id=None):
|
|
70
|
+
self.set_status(204)
|
|
71
|
+
if session_id:
|
|
72
|
+
del self._sessions[session_id]
|
|
73
|
+
callback = getattr(_.application, f'on_{self._component}_delete', None)
|
|
74
|
+
if callback is None:
|
|
75
|
+
callback = getattr(_.application, 'on_memory_delete', None)
|
|
76
|
+
if callback:
|
|
77
|
+
await _.wait(callback(self._component, session_id))
|
_/caches/redis.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#
|
|
2
|
+
# (c) 2015-2023 Matthew Shaw
|
|
3
|
+
#
|
|
4
|
+
# Authors
|
|
5
|
+
# =======
|
|
6
|
+
# Matthew Shaw <mshaw.cx@gmail.com>
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
import _
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import redis.asyncio as redis
|
|
17
|
+
except ImportError:
|
|
18
|
+
raise _.error('Missing redis module with asyncio support')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Redis(_.caches.Cache):
|
|
22
|
+
async def init(self, component_name, **kwds):
|
|
23
|
+
if 'socket_connect_timeout' not in kwds:
|
|
24
|
+
kwds['socket_connect_timeout'] = 3
|
|
25
|
+
|
|
26
|
+
if 'socket_timeout' not in kwds:
|
|
27
|
+
kwds['socket_timeout'] = 3
|
|
28
|
+
|
|
29
|
+
self.redis = redis.Redis(**kwds)
|
|
30
|
+
try:
|
|
31
|
+
await self.redis.ping()
|
|
32
|
+
except Exception as e:
|
|
33
|
+
raise _.error('Redis: %s: %s', component_name, e)
|
|
34
|
+
|
|
35
|
+
members = dict(
|
|
36
|
+
component = component_name,
|
|
37
|
+
redis = self.redis,
|
|
38
|
+
)
|
|
39
|
+
subclass = type(component_name, (RedisSessions,), _.prefix(members))
|
|
40
|
+
_.application._record_handler('sessions', subclass)
|
|
41
|
+
|
|
42
|
+
async def close(self):
|
|
43
|
+
await self.redis.aclose()
|
|
44
|
+
self.redis = None
|
|
45
|
+
|
|
46
|
+
async def cookie_secret(self):
|
|
47
|
+
secret = await self.redis.get('cookie_secret')
|
|
48
|
+
if not secret:
|
|
49
|
+
secret = base64.b64encode(os.urandom(32))
|
|
50
|
+
await self.redis.set('cookie_secret', secret)
|
|
51
|
+
return secret
|
|
52
|
+
|
|
53
|
+
async def save_session(self, session):
|
|
54
|
+
try:
|
|
55
|
+
session_id = session['session_id']
|
|
56
|
+
except KeyError:
|
|
57
|
+
raise _.error('No session_id defined in session')
|
|
58
|
+
async with self.redis.pipeline(transaction=True) as pipe:
|
|
59
|
+
await pipe.set(f'session/{session_id}', json.dumps(session))
|
|
60
|
+
await pipe.expire(f'session/{session_id}', self._expires * 3600)
|
|
61
|
+
await pipe.execute()
|
|
62
|
+
|
|
63
|
+
async def load_session(self, session_id):
|
|
64
|
+
session = await self.redis.get(f'session/{session_id}')
|
|
65
|
+
if not session:
|
|
66
|
+
return None
|
|
67
|
+
return json.loads(session)
|
|
68
|
+
|
|
69
|
+
async def clear_session(self, session_id):
|
|
70
|
+
session = await self.redis.delete(f'session/{session_id}')
|
|
71
|
+
|
|
72
|
+
# fall through for calling redis functions directly
|
|
73
|
+
def __getattr__(self, attr):
|
|
74
|
+
return getattr(self.redis, attr)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class RedisSessions(_.handlers.Protected):
|
|
78
|
+
@_.auth.protected
|
|
79
|
+
async def get(self, session_id=None):
|
|
80
|
+
if session_id:
|
|
81
|
+
session = await self._redis.get(f'session/{session_id}')
|
|
82
|
+
if not session:
|
|
83
|
+
raise _.HTTPError(404)
|
|
84
|
+
session = json.loads(session)
|
|
85
|
+
self.write(session)
|
|
86
|
+
else:
|
|
87
|
+
data = []
|
|
88
|
+
session_ids = await self._redis.keys('session/*')
|
|
89
|
+
for session_id in session_ids:
|
|
90
|
+
session = await self._redis.get(session_id)
|
|
91
|
+
data.append(json.loads(session))
|
|
92
|
+
data.sort(key=lambda d: d['time'])
|
|
93
|
+
self.write({'data':data})
|
|
94
|
+
|
|
95
|
+
@_.auth.protected
|
|
96
|
+
async def delete(self, session_id=None):
|
|
97
|
+
self.set_status(204)
|
|
98
|
+
if session_id:
|
|
99
|
+
await self._redis.delete(f'session/{session_id}')
|
|
100
|
+
callback = getattr(_.application, f'on_{self._component}_delete', None)
|
|
101
|
+
if callback is None:
|
|
102
|
+
callback = getattr(_.application, 'on_redis_delete', None)
|
|
103
|
+
if callback:
|
|
104
|
+
await _.wait(callback(self._component, session_id))
|
_/components.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#
|
|
2
|
+
# (c) 2015-2023 Matthew Shaw
|
|
3
|
+
#
|
|
4
|
+
# Authors
|
|
5
|
+
# =======
|
|
6
|
+
# Matthew Shaw <mshaw.cx@gmail.com>
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
import logging
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
import _
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Component(type(_)):
|
|
17
|
+
def __init__(self, module):
|
|
18
|
+
super().__init__(module.__name__)
|
|
19
|
+
self.module = module
|
|
20
|
+
self._components = {}
|
|
21
|
+
|
|
22
|
+
def __getattr__(self, name):
|
|
23
|
+
if name in self._components:
|
|
24
|
+
return self._components[name]
|
|
25
|
+
return getattr(self.module, name)
|
|
26
|
+
|
|
27
|
+
def __getitem__(self, name):
|
|
28
|
+
return self._components[name]
|
|
29
|
+
|
|
30
|
+
def __setitem__(self, name, value):
|
|
31
|
+
self._components[name] = value
|
|
32
|
+
|
|
33
|
+
def keys(self):
|
|
34
|
+
return self._components.keys()
|
|
35
|
+
|
|
36
|
+
def values(self):
|
|
37
|
+
return self._components.values()
|
|
38
|
+
|
|
39
|
+
def items(self):
|
|
40
|
+
return self._components.items()
|
|
41
|
+
|
|
42
|
+
def __str__(self):
|
|
43
|
+
return str(self._components)
|
|
44
|
+
|
|
45
|
+
def __len__(self):
|
|
46
|
+
return len(self._components)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def load(component_type):
|
|
50
|
+
# import the component submodule from _
|
|
51
|
+
module = importlib.import_module(f'_.{component_type}')
|
|
52
|
+
# wrap the submodule in a Component class, even when none are configured,
|
|
53
|
+
# since a later "import _.records" would otherwise leave the bare module
|
|
54
|
+
setattr(_, component_type, Component(module))
|
|
55
|
+
|
|
56
|
+
# skip components not specified in the config
|
|
57
|
+
if component_type not in _.config:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
# iterate over the components specified in the config
|
|
61
|
+
for component_name in _.config[component_type]:
|
|
62
|
+
# check if the component is aliased
|
|
63
|
+
component = _.config[component_type][component_name]
|
|
64
|
+
if component is None:
|
|
65
|
+
component = component_name
|
|
66
|
+
|
|
67
|
+
# allow import 3rd-party components, specified using + prefix
|
|
68
|
+
if not component.startswith('+'):
|
|
69
|
+
import_path = f'_.{component_type}.{component}'
|
|
70
|
+
attr = None
|
|
71
|
+
else:
|
|
72
|
+
try:
|
|
73
|
+
component,attr = component.rsplit('.', 1)
|
|
74
|
+
except ValueError:
|
|
75
|
+
attr = None
|
|
76
|
+
import_path = component[1:]
|
|
77
|
+
|
|
78
|
+
# attempt to import the module
|
|
79
|
+
try:
|
|
80
|
+
module = importlib.import_module(import_path)
|
|
81
|
+
except ModuleNotFoundError as e:
|
|
82
|
+
raise _.error('Unknown module: %s: %s', import_path, e)
|
|
83
|
+
|
|
84
|
+
cls = None
|
|
85
|
+
if not attr:
|
|
86
|
+
for attr_name in dir(module):
|
|
87
|
+
attr = getattr(module, attr_name)
|
|
88
|
+
# _ components are types
|
|
89
|
+
if not isinstance(attr, type):
|
|
90
|
+
continue
|
|
91
|
+
# _ components have _ member function
|
|
92
|
+
if not hasattr(attr, '_'):
|
|
93
|
+
continue
|
|
94
|
+
cls = attr
|
|
95
|
+
break
|
|
96
|
+
else:
|
|
97
|
+
cls = getattr(module, attr)
|
|
98
|
+
|
|
99
|
+
if not cls:
|
|
100
|
+
raise _.error('%s: %s module not found', component, component_type)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
kwds = dict(_.config[component_name])
|
|
104
|
+
except KeyError:
|
|
105
|
+
kwds = {}
|
|
106
|
+
|
|
107
|
+
await cls._(component_name, **kwds)
|
_/databases/__init__.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
#
|
|
2
|
+
# (c) 2015-2024 Matthew Shaw
|
|
3
|
+
#
|
|
4
|
+
# Authors
|
|
5
|
+
# =======
|
|
6
|
+
# Matthew Shaw <mshaw.cx@gmail.com>
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import io
|
|
11
|
+
import logging
|
|
12
|
+
import typing
|
|
13
|
+
|
|
14
|
+
import _
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import sqlalchemy
|
|
18
|
+
import sqlalchemy.types
|
|
19
|
+
import sqlalchemy.orm
|
|
20
|
+
from sqlalchemy.ext.asyncio import AsyncSession,create_async_engine
|
|
21
|
+
except ImportError:
|
|
22
|
+
raise _.error('Missing sqlalchemy module')
|
|
23
|
+
|
|
24
|
+
logging.getLogger('alembic').setLevel(logging.WARNING)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UInt64(sqlalchemy.types.TypeDecorator):
|
|
28
|
+
'''unsigned 64-bit integer: NUMERIC(20) on Postgres, otherwise stored in
|
|
29
|
+
a BIGINT as its two's complement so values of 2**63 and up fit'''
|
|
30
|
+
impl = sqlalchemy.BIGINT
|
|
31
|
+
cache_ok = True
|
|
32
|
+
|
|
33
|
+
def load_dialect_impl(self, dialect):
|
|
34
|
+
if dialect.name == 'postgresql':
|
|
35
|
+
return dialect.type_descriptor(sqlalchemy.NUMERIC(20, 0))
|
|
36
|
+
return dialect.type_descriptor(sqlalchemy.BIGINT())
|
|
37
|
+
|
|
38
|
+
def process_bind_param(self, value, dialect):
|
|
39
|
+
if value is None or dialect.name == 'postgresql':
|
|
40
|
+
return value
|
|
41
|
+
return value - (1 << 64) if value >= (1 << 63) else value
|
|
42
|
+
|
|
43
|
+
def process_result_value(self, value, dialect):
|
|
44
|
+
if value is None:
|
|
45
|
+
return value
|
|
46
|
+
if dialect.name == 'postgresql':
|
|
47
|
+
return int(value)
|
|
48
|
+
return value + (1 << 64) if value < 0 else value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class JSONArray(sqlalchemy.types.TypeDecorator):
|
|
52
|
+
'list stored as JSON for databases without arrays, bytes as base64'
|
|
53
|
+
impl = sqlalchemy.JSON
|
|
54
|
+
cache_ok = True
|
|
55
|
+
|
|
56
|
+
def __init__(self, binary=False):
|
|
57
|
+
super().__init__()
|
|
58
|
+
self.binary = binary
|
|
59
|
+
|
|
60
|
+
def process_bind_param(self, value, dialect):
|
|
61
|
+
if value is not None and self.binary:
|
|
62
|
+
value = [base64.b64encode(v).decode('ascii') for v in value]
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
def process_result_value(self, value, dialect):
|
|
66
|
+
if value is not None and self.binary:
|
|
67
|
+
value = [base64.b64decode(v) for v in value]
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Database:
|
|
72
|
+
TEXT = sqlalchemy.TEXT
|
|
73
|
+
INTEGER = sqlalchemy.INTEGER
|
|
74
|
+
BIGINT = sqlalchemy.BIGINT
|
|
75
|
+
#DOUBLE = sqlalchemy.DOUBLE_PRECISION
|
|
76
|
+
NUMERIC = sqlalchemy.NUMERIC
|
|
77
|
+
REAL = sqlalchemy.REAL
|
|
78
|
+
BOOLEAN = sqlalchemy.BOOLEAN
|
|
79
|
+
JSON = None
|
|
80
|
+
BYTES = None
|
|
81
|
+
UUID = None
|
|
82
|
+
|
|
83
|
+
TYPE_ANNOTATION_MAP = {}
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
async def _(cls, component_name, **kwds):
|
|
87
|
+
self = cls()
|
|
88
|
+
self.component_name = component_name
|
|
89
|
+
# each database gets its own models and metadata
|
|
90
|
+
self.Base = self._declarative_base()
|
|
91
|
+
self.check_schema = kwds.pop('check_schema', 'true').lower() not in ('false', 'no', 'off', '0')
|
|
92
|
+
_.databases[component_name] = self
|
|
93
|
+
setattr(_, component_name, self)
|
|
94
|
+
|
|
95
|
+
_.argparser.add_argument(f'--{component_name}-migrate',
|
|
96
|
+
action='store_true',
|
|
97
|
+
help=f'Apply schema changes to the {component_name} database'
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
await self.init(component_name, **kwds)
|
|
101
|
+
|
|
102
|
+
def _declarative_base(self):
|
|
103
|
+
class Base(
|
|
104
|
+
sqlalchemy.ext.asyncio.AsyncAttrs,
|
|
105
|
+
sqlalchemy.orm.MappedAsDataclass,
|
|
106
|
+
sqlalchemy.orm.DeclarativeBase,
|
|
107
|
+
):
|
|
108
|
+
'base class for _.records'
|
|
109
|
+
type_annotation_map = self.TYPE_ANNOTATION_MAP
|
|
110
|
+
return Base
|
|
111
|
+
|
|
112
|
+
async def init(self, component_name, **kwds):
|
|
113
|
+
kwds['drivername'] = self.DRIVER
|
|
114
|
+
url = sqlalchemy.engine.URL.create(**kwds)
|
|
115
|
+
logging.debug('Database URL: %s: %s', component_name, url)
|
|
116
|
+
self.engine = create_async_engine(url, echo=False)
|
|
117
|
+
self.session = sqlalchemy.orm.sessionmaker(self.engine, class_=AsyncSession)
|
|
118
|
+
|
|
119
|
+
def array(self, item_type):
|
|
120
|
+
'column type for a list of item_type, overridden by databases with arrays'
|
|
121
|
+
return JSONArray(binary=isinstance(item_type, sqlalchemy.LargeBinary))
|
|
122
|
+
|
|
123
|
+
async def create_tables(self):
|
|
124
|
+
'''create the tables in an empty database, otherwise compare them to the
|
|
125
|
+
models and apply the changes if --<name>-migrate was given'''
|
|
126
|
+
try:
|
|
127
|
+
async with self.engine.begin() as conn:
|
|
128
|
+
await conn.run_sync(self._sync_schema)
|
|
129
|
+
except _.error:
|
|
130
|
+
raise
|
|
131
|
+
except Exception as e:
|
|
132
|
+
raise _.error('Database "%s": %s', self.component_name, e) from None
|
|
133
|
+
|
|
134
|
+
def _sync_schema(self, conn):
|
|
135
|
+
metadata = self.Base.metadata
|
|
136
|
+
existing = set(sqlalchemy.inspect(conn).get_table_names())
|
|
137
|
+
if not self.check_schema or not existing & set(metadata.tables):
|
|
138
|
+
metadata.create_all(conn)
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
import alembic.migration
|
|
143
|
+
import alembic.operations
|
|
144
|
+
except ImportError:
|
|
145
|
+
logging.warning('Database "%s": install alembic to check the schema', self.component_name)
|
|
146
|
+
metadata.create_all(conn)
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
apply,manual = _plan_schema(conn, metadata)
|
|
150
|
+
migrate = getattr(_.args, f'{self.component_name}_migrate', False)
|
|
151
|
+
|
|
152
|
+
# apply nothing unless everything can be applied, SQLite can't roll back DDL
|
|
153
|
+
if apply and migrate and not manual:
|
|
154
|
+
operations = alembic.operations.Operations(alembic.migration.MigrationContext.configure(conn))
|
|
155
|
+
for op in apply:
|
|
156
|
+
logging.info('%s: %s', self.component_name, _render_sql(conn, op))
|
|
157
|
+
operations.invoke(op)
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
if not apply and not manual:
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
lines = [f'Database "{self.component_name}" differs from the schema:']
|
|
164
|
+
lines.extend(f' {_render_sql(conn, op)}' for op in apply)
|
|
165
|
+
if manual:
|
|
166
|
+
lines.append('These changes may lose data and must be applied by hand:')
|
|
167
|
+
lines.extend(f' {_render_sql(conn, op)}' for op in manual)
|
|
168
|
+
if apply:
|
|
169
|
+
lines.append(f'Then run with --{self.component_name}-migrate to apply the rest')
|
|
170
|
+
else:
|
|
171
|
+
lines.append(f'Run with --{self.component_name}-migrate to apply them')
|
|
172
|
+
raise _.error('%s', '\n'.join(lines))
|
|
173
|
+
|
|
174
|
+
async def close(self):
|
|
175
|
+
await self.engine.dispose()
|
|
176
|
+
|
|
177
|
+
# CREATE
|
|
178
|
+
async def insert(self, *args):
|
|
179
|
+
try:
|
|
180
|
+
async with self.session() as session:
|
|
181
|
+
async with session.begin():
|
|
182
|
+
session.add_all(args)
|
|
183
|
+
except sqlalchemy.exc.IntegrityError as e:
|
|
184
|
+
raise _.error('%s', e) from None
|
|
185
|
+
|
|
186
|
+
# READ
|
|
187
|
+
async def find(self, cls, value=None, column=None):
|
|
188
|
+
statement = sqlalchemy.select(cls)
|
|
189
|
+
if value:
|
|
190
|
+
column = getattr(cls, column or cls.__primary_key__)
|
|
191
|
+
statement = statement.where(column == value)
|
|
192
|
+
async with self.session() as session:
|
|
193
|
+
results = await session.execute(statement)
|
|
194
|
+
return results.unique().scalars().all()
|
|
195
|
+
|
|
196
|
+
async def find_one(self, cls, value, column=None):
|
|
197
|
+
statement = sqlalchemy.select(cls)
|
|
198
|
+
column = getattr(cls, column or cls.__primary_key__)
|
|
199
|
+
statement = statement.where(column == value)
|
|
200
|
+
async with self.session() as session:
|
|
201
|
+
results = await session.execute(statement)
|
|
202
|
+
return results.unique().scalars().first()
|
|
203
|
+
|
|
204
|
+
# UPDATE
|
|
205
|
+
async def upsert(self, *args):
|
|
206
|
+
async with self.session() as session:
|
|
207
|
+
async with session.begin():
|
|
208
|
+
for obj in args:
|
|
209
|
+
await session.merge(obj)
|
|
210
|
+
|
|
211
|
+
# DELETE
|
|
212
|
+
async def delete(self, obj, value=None):
|
|
213
|
+
async with self.session() as session:
|
|
214
|
+
async with session.begin():
|
|
215
|
+
if value is None:
|
|
216
|
+
# delete an ORM instance
|
|
217
|
+
await session.delete(obj)
|
|
218
|
+
else:
|
|
219
|
+
# otherwise assume obj is the SQLalchemy table class
|
|
220
|
+
column = getattr(obj, obj.__primary_key__)
|
|
221
|
+
statement = sqlalchemy.delete(obj).where(column == value)
|
|
222
|
+
results = await session.execute(statement)
|
|
223
|
+
return results.rowcount
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _plan_schema(conn, metadata):
|
|
227
|
+
'''compare the database with the models using alembic autogenerate and split
|
|
228
|
+
the changes into those that are safe to apply and those that may lose data'''
|
|
229
|
+
import alembic.autogenerate
|
|
230
|
+
import alembic.migration
|
|
231
|
+
import alembic.operations.ops as ops
|
|
232
|
+
|
|
233
|
+
# ignore tables in the database that these models don't define
|
|
234
|
+
def include_name(name, type_, parent_names):
|
|
235
|
+
return type_ != 'table' or name in metadata.tables
|
|
236
|
+
|
|
237
|
+
context = alembic.migration.MigrationContext.configure(conn, opts=dict(
|
|
238
|
+
include_name = include_name,
|
|
239
|
+
compare_type = True,
|
|
240
|
+
))
|
|
241
|
+
script = alembic.autogenerate.produce_migrations(context, metadata)
|
|
242
|
+
|
|
243
|
+
apply,manual = [],[]
|
|
244
|
+
for op in script.upgrade_ops.ops:
|
|
245
|
+
for op in op.ops if isinstance(op, ops.ModifyTableOps) else [op]:
|
|
246
|
+
if _pkey_nullable_only(metadata, op):
|
|
247
|
+
continue
|
|
248
|
+
if isinstance(op, (ops.DropTableOp, ops.DropColumnOp, ops.AlterColumnOp)):
|
|
249
|
+
manual.append(op)
|
|
250
|
+
elif isinstance(op, ops.AddColumnOp) and not (op.column.nullable or op.column.server_default):
|
|
251
|
+
# existing rows would need a value
|
|
252
|
+
manual.append(op)
|
|
253
|
+
else:
|
|
254
|
+
apply.append(op)
|
|
255
|
+
return apply,manual
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _pkey_nullable_only(metadata, op):
|
|
259
|
+
'''primary keys are never null, but models declare them Optional and
|
|
260
|
+
SQLite only adds NOT NULL when asked, so ignore that difference'''
|
|
261
|
+
import alembic.operations.ops as ops
|
|
262
|
+
|
|
263
|
+
if not isinstance(op, ops.AlterColumnOp) or op.modify_nullable is None:
|
|
264
|
+
return False
|
|
265
|
+
if op.modify_type is not None or op.modify_server_default is not False or op.modify_name is not None:
|
|
266
|
+
return False
|
|
267
|
+
table = metadata.tables.get(op.table_name)
|
|
268
|
+
return table is not None and op.column_name in table.c and table.c[op.column_name].primary_key
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _render_sql(conn, op):
|
|
272
|
+
'the SQL for an alembic operation, for logging and error messages'
|
|
273
|
+
import alembic.migration
|
|
274
|
+
import alembic.operations
|
|
275
|
+
|
|
276
|
+
output = io.StringIO()
|
|
277
|
+
context = alembic.migration.MigrationContext.configure(
|
|
278
|
+
dialect_name = conn.dialect.name,
|
|
279
|
+
opts = dict(as_sql=True, output_buffer=output),
|
|
280
|
+
)
|
|
281
|
+
alembic.operations.Operations(context).invoke(op)
|
|
282
|
+
return ' '.join(output.getvalue().split()).rstrip(';')
|
_/databases/postgres.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#
|
|
2
|
+
# (c) 2015-2024 Matthew Shaw
|
|
3
|
+
#
|
|
4
|
+
# Authors
|
|
5
|
+
# =======
|
|
6
|
+
# Matthew Shaw <mshaw.cx@gmail.com>
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import typing
|
|
11
|
+
|
|
12
|
+
import _
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import asyncpg
|
|
16
|
+
except ImportError:
|
|
17
|
+
raise _.error('Missing asyncpg module')
|
|
18
|
+
|
|
19
|
+
import sqlalchemy
|
|
20
|
+
import sqlalchemy.dialects.postgresql
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Postgres(_.databases.Database):
|
|
24
|
+
DRIVER = 'postgresql+asyncpg'
|
|
25
|
+
|
|
26
|
+
ARRAY = sqlalchemy.dialects.postgresql.ARRAY
|
|
27
|
+
JSON = sqlalchemy.dialects.postgresql.JSONB
|
|
28
|
+
BYTES = sqlalchemy.dialects.postgresql.BYTEA
|
|
29
|
+
UUID = sqlalchemy.dialects.postgresql.UUID
|
|
30
|
+
|
|
31
|
+
TYPE_ANNOTATION_MAP = {
|
|
32
|
+
int: sqlalchemy.BIGINT,
|
|
33
|
+
dict[str, typing.Any]: sqlalchemy.dialects.postgresql.JSONB,
|
|
34
|
+
list[dict[str, typing.Any]]: sqlalchemy.dialects.postgresql.JSONB,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
def array(self, item_type):
|
|
38
|
+
return self.ARRAY(item_type)
|
_/databases/sqlite.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#
|
|
2
|
+
# (c) 2015-2024 Matthew Shaw
|
|
3
|
+
#
|
|
4
|
+
# Authors
|
|
5
|
+
# =======
|
|
6
|
+
# Matthew Shaw <mshaw.cx@gmail.com>
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import typing
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
import _
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import aiosqlite
|
|
17
|
+
except ImportError:
|
|
18
|
+
raise _.error('Missing aiosqlite module')
|
|
19
|
+
|
|
20
|
+
logging.getLogger("aiosqlite").setLevel(logging.WARNING)
|
|
21
|
+
|
|
22
|
+
import sqlalchemy
|
|
23
|
+
import sqlalchemy.engine
|
|
24
|
+
import sqlalchemy.dialects.sqlite
|
|
25
|
+
|
|
26
|
+
#aiosqlite.register_adapter(bool, int)
|
|
27
|
+
#aiosqlite.register_converter('BOOLEAN', lambda v: bool(int(v)))
|
|
28
|
+
|
|
29
|
+
#aiosqlite.register_adapter(uuid.UUID, str)
|
|
30
|
+
#aiosqlite.register_converter('UUID', lambda v: uuid.UUID(v))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UUID(sqlalchemy.types.TypeDecorator):
|
|
34
|
+
impl = sqlalchemy.types.CHAR
|
|
35
|
+
|
|
36
|
+
def load_dialect_impl(self, dialect):
|
|
37
|
+
return dialect.type_descriptor(sqlalchemy.types.CHAR(32))
|
|
38
|
+
|
|
39
|
+
def process_bind_param(self, value, dialect):
|
|
40
|
+
if value is None:
|
|
41
|
+
return value
|
|
42
|
+
if not isinstance(value, uuid.UUID):
|
|
43
|
+
return '%.32x' % uuid.UUID(value).int
|
|
44
|
+
return '%.32x' % value.int
|
|
45
|
+
|
|
46
|
+
def process_result_value(self, value, dialect):
|
|
47
|
+
if value is None:
|
|
48
|
+
return value
|
|
49
|
+
if not isinstance(value, uuid.UUID):
|
|
50
|
+
value = uuid.UUID(value)
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SQLite(_.databases.Database):
|
|
55
|
+
DRIVER = 'sqlite+aiosqlite'
|
|
56
|
+
|
|
57
|
+
ARRAY = sqlalchemy.dialects.sqlite.JSON
|
|
58
|
+
JSON = sqlalchemy.dialects.sqlite.JSON
|
|
59
|
+
BYTES = sqlalchemy.dialects.sqlite.BLOB
|
|
60
|
+
BOOL = sqlalchemy.dialects.sqlite.BOOLEAN
|
|
61
|
+
UUID = UUID
|
|
62
|
+
|
|
63
|
+
TYPE_ANNOTATION_MAP = {
|
|
64
|
+
int: sqlalchemy.BIGINT,
|
|
65
|
+
dict[str, typing.Any]: sqlalchemy.dialects.sqlite.JSON,
|
|
66
|
+
list[dict[str, typing.Any]]: sqlalchemy.dialects.sqlite.JSON,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async def init(self, component_name, **kwds):
|
|
70
|
+
logging.getLogger('aiosqlite').setLevel(logging.WARNING)
|
|
71
|
+
await super(SQLite, self).init(component_name, **kwds)
|
|
72
|
+
|
|
73
|
+
# SQLite enforces foreign keys per connection and only when asked
|
|
74
|
+
@sqlalchemy.event.listens_for(self.engine.sync_engine, 'connect')
|
|
75
|
+
def foreign_keys(dbapi_conn, record):
|
|
76
|
+
cursor = dbapi_conn.cursor()
|
|
77
|
+
cursor.execute('PRAGMA foreign_keys=ON')
|
|
78
|
+
cursor.close()
|