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.
_/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)
@@ -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(';')
@@ -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()