underscorepy 0.9.0__tar.gz → 0.10.0__tar.gz
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.
- {underscorepy-0.9.0 → underscorepy-0.10.0}/PKG-INFO +1 -1
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/caches/memory.py +1 -1
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/caches/redis.py +1 -1
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/databases/__init__.py +35 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/handlers.py +12 -1
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/logins/__init__.py +3 -3
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/logins/dblogin.py +2 -2
- underscorepy-0.10.0/_/records/Protobuf.proto +32 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/records/__init__.py +8 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/records/data.py +22 -2
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/records/protobuf.py +273 -52
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/supports/nginx/__init__.py +2 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/supports/nginx/nginx.conf +6 -6
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/supports/systemd/__init__.py +18 -1
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/supports/systemd/systemd.service +1 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/utils.py +18 -3
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/version.py +1 -1
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/websockets.py +6 -0
- underscorepy-0.10.0/tests/test_application.py +148 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/tests/test_auth.py +21 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/tests/test_components.py +82 -0
- underscorepy-0.10.0/tests/test_protobuf.py +718 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/tests/test_records.py +24 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/underscorepy.egg-info/PKG-INFO +1 -1
- underscorepy-0.9.0/_/records/Protobuf.proto +0 -21
- underscorepy-0.9.0/tests/test_application.py +0 -80
- underscorepy-0.9.0/tests/test_protobuf.py +0 -408
- {underscorepy-0.9.0 → underscorepy-0.10.0}/LICENSE +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/README.md +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/__init__.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/application.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/auth.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/caches/__init__.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/caches/dbcache.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/components.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/databases/postgres.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/databases/sqlite.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/logins/facebook.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/logins/gitlab.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/logins/google.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/logins/htpasswd.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/logins/oauth2.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/logins/slap.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/settings.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/supports/__init__.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/_/test.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/pyproject.toml +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/setup.cfg +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/setup.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/tests/test_migrations.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/tests/test_settings.py +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/underscorepy.egg-info/SOURCES.txt +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/underscorepy.egg-info/dependency_links.txt +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/underscorepy.egg-info/not-zip-safe +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/underscorepy.egg-info/requires.txt +0 -0
- {underscorepy-0.9.0 → underscorepy-0.10.0}/underscorepy.egg-info/top_level.txt +0 -0
|
@@ -34,7 +34,7 @@ class Memory(_.caches.Cache):
|
|
|
34
34
|
session_id = session['session_id']
|
|
35
35
|
except KeyError:
|
|
36
36
|
raise _.error('No session_id defined in session')
|
|
37
|
-
self.sessions[session_id] =
|
|
37
|
+
self.sessions[session_id] = _.json_encode(session)
|
|
38
38
|
|
|
39
39
|
async def load_session(self, session_id):
|
|
40
40
|
session = self.sessions.get(session_id, None)
|
|
@@ -56,7 +56,7 @@ class Redis(_.caches.Cache):
|
|
|
56
56
|
except KeyError:
|
|
57
57
|
raise _.error('No session_id defined in session')
|
|
58
58
|
async with self.redis.pipeline(transaction=True) as pipe:
|
|
59
|
-
await pipe.set(f'session/{session_id}',
|
|
59
|
+
await pipe.set(f'session/{session_id}', _.json_encode(session))
|
|
60
60
|
await pipe.expire(f'session/{session_id}', self._expires * 3600)
|
|
61
61
|
await pipe.execute()
|
|
62
62
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
#
|
|
8
8
|
|
|
9
9
|
import base64
|
|
10
|
+
import datetime
|
|
10
11
|
import io
|
|
11
12
|
import logging
|
|
12
13
|
import typing
|
|
@@ -48,6 +49,30 @@ class UInt64(sqlalchemy.types.TypeDecorator):
|
|
|
48
49
|
return value + (1 << 64) if value < 0 else value
|
|
49
50
|
|
|
50
51
|
|
|
52
|
+
class UTCDateTime(sqlalchemy.types.TypeDecorator):
|
|
53
|
+
'''timezone-aware datetime: TIMESTAMPTZ on Postgres, UTC without a zone on
|
|
54
|
+
SQLite, always returned in UTC with tzinfo set; naive values are UTC'''
|
|
55
|
+
impl = sqlalchemy.DateTime(timezone=True)
|
|
56
|
+
cache_ok = True
|
|
57
|
+
|
|
58
|
+
def process_bind_param(self, value, dialect):
|
|
59
|
+
if value is None:
|
|
60
|
+
return value
|
|
61
|
+
if value.tzinfo is None:
|
|
62
|
+
value = value.replace(tzinfo=datetime.timezone.utc)
|
|
63
|
+
value = value.astimezone(datetime.timezone.utc)
|
|
64
|
+
if dialect.name != 'postgresql':
|
|
65
|
+
value = value.replace(tzinfo=None)
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
def process_result_value(self, value, dialect):
|
|
69
|
+
if value is None:
|
|
70
|
+
return value
|
|
71
|
+
if value.tzinfo is None:
|
|
72
|
+
return value.replace(tzinfo=datetime.timezone.utc)
|
|
73
|
+
return value.astimezone(datetime.timezone.utc)
|
|
74
|
+
|
|
75
|
+
|
|
51
76
|
class JSONArray(sqlalchemy.types.TypeDecorator):
|
|
52
77
|
'list stored as JSON for databases without arrays, bytes as base64'
|
|
53
78
|
impl = sqlalchemy.JSON
|
|
@@ -176,6 +201,7 @@ class Database:
|
|
|
176
201
|
|
|
177
202
|
# CREATE
|
|
178
203
|
async def insert(self, *args):
|
|
204
|
+
_before_save(args)
|
|
179
205
|
try:
|
|
180
206
|
async with self.session() as session:
|
|
181
207
|
async with session.begin():
|
|
@@ -203,6 +229,7 @@ class Database:
|
|
|
203
229
|
|
|
204
230
|
# UPDATE
|
|
205
231
|
async def upsert(self, *args):
|
|
232
|
+
_before_save(args)
|
|
206
233
|
async with self.session() as session:
|
|
207
234
|
async with session.begin():
|
|
208
235
|
for obj in args:
|
|
@@ -223,6 +250,14 @@ class Database:
|
|
|
223
250
|
return results.rowcount
|
|
224
251
|
|
|
225
252
|
|
|
253
|
+
def _before_save(records):
|
|
254
|
+
'let records prepare themselves, e.g. number their children, before an insert or merge'
|
|
255
|
+
for record in records:
|
|
256
|
+
hook = getattr(record, '_before_save', None)
|
|
257
|
+
if hook:
|
|
258
|
+
hook()
|
|
259
|
+
|
|
260
|
+
|
|
226
261
|
def _plan_schema(conn, metadata):
|
|
227
262
|
'''compare the database with the models using alembic autogenerate and split
|
|
228
263
|
the changes into those that are safe to apply and those that may lose data'''
|
|
@@ -13,7 +13,18 @@ import tornado.web
|
|
|
13
13
|
import _
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
class
|
|
16
|
+
class Handler(tornado.web.RequestHandler):
|
|
17
|
+
'''base for _ handlers, writes dicts and lists as JSON with _.JSONEncoder so
|
|
18
|
+
records, bytes and datetimes can be written directly'''
|
|
19
|
+
def write(self, chunk):
|
|
20
|
+
if isinstance(chunk, (dict, list)):
|
|
21
|
+
self.set_header('Content-Type', 'application/json; charset=UTF-8')
|
|
22
|
+
# like tornado, escape </ so the JSON is safe inside a script tag
|
|
23
|
+
chunk = _.json_encode(chunk).replace('</', '<\\/')
|
|
24
|
+
super().write(chunk)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Template(Handler):
|
|
17
28
|
def initialize(self, template='index', **kwds):
|
|
18
29
|
self.kwds = kwds
|
|
19
30
|
self.template = template + '.html'
|
|
@@ -6,7 +6,7 @@ import tornado.web
|
|
|
6
6
|
import _
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
class Login(
|
|
9
|
+
class Login(_.handlers.Handler):
|
|
10
10
|
@classmethod
|
|
11
11
|
async def _(cls, component_name, **kwds):
|
|
12
12
|
# create a dynamic child class with kwds from the ini file
|
|
@@ -57,7 +57,7 @@ class Login(tornado.web.RequestHandler):
|
|
|
57
57
|
self.redirect(url)
|
|
58
58
|
|
|
59
59
|
|
|
60
|
-
class LoginPage(
|
|
60
|
+
class LoginPage(_.handlers.Handler):
|
|
61
61
|
def initialize(self, template='login', **kwds):
|
|
62
62
|
self.kwds = kwds
|
|
63
63
|
self.template = template + '.html'
|
|
@@ -69,7 +69,7 @@ class LoginPage(tornado.web.RequestHandler):
|
|
|
69
69
|
self.render(template, message=message, next_url=next_url, **self.kwds)
|
|
70
70
|
|
|
71
71
|
|
|
72
|
-
class Logout(
|
|
72
|
+
class Logout(_.handlers.Handler):
|
|
73
73
|
async def get(self):
|
|
74
74
|
session_id = self.get_signed_cookie('session_id', max_age_days=1)
|
|
75
75
|
|
|
@@ -32,8 +32,8 @@ class DbLogin(_.logins.Login):
|
|
|
32
32
|
raise _.error('dblogin requires a database to be specified')
|
|
33
33
|
try:
|
|
34
34
|
cls._db = _.databases[cls._database]
|
|
35
|
-
except
|
|
36
|
-
raise _.error('
|
|
35
|
+
except KeyError:
|
|
36
|
+
raise _.error('Unknown database "%s" for %s', cls._database, component_name) from None
|
|
37
37
|
|
|
38
38
|
# if only one login specified use short argument
|
|
39
39
|
prefix = f'{component_name}-' if len(_.config['logins']) > 1 else ''
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
syntax = "proto3";
|
|
2
|
+
|
|
3
|
+
import "google/protobuf/descriptor.proto";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
extend google.protobuf.MessageOptions {
|
|
7
|
+
// name of an auto-increment primary key column, e.g. 'order_id'
|
|
8
|
+
string id = 50001;
|
|
9
|
+
// no database table for the message
|
|
10
|
+
bool no_table = 50002;
|
|
11
|
+
// no REST handler for the message
|
|
12
|
+
bool no_handler = 50003;
|
|
13
|
+
// 50004 was refs, replaced by the (ref) field option; don't reuse it
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
extend google.protobuf.FieldOptions {
|
|
17
|
+
// primary key column
|
|
18
|
+
bool pkey = 50001;
|
|
19
|
+
// kept in the message but not stored
|
|
20
|
+
bool no_column = 50002;
|
|
21
|
+
// unique column, or with (paired) a unique group of columns
|
|
22
|
+
bool uniq = 50003;
|
|
23
|
+
// NOT NULL, and refused before saving when unset
|
|
24
|
+
bool required = 50004;
|
|
25
|
+
// foreign key to "Table.column"
|
|
26
|
+
string ref = 50005;
|
|
27
|
+
// group name; fields in the same group share one composite index,
|
|
28
|
+
// or a unique constraint when they are all (uniq), e.g. "geo"
|
|
29
|
+
string paired = 50006;
|
|
30
|
+
// storage hint, "uuid" for a native UUID column on a string field
|
|
31
|
+
string subtype = 50007;
|
|
32
|
+
}
|
|
@@ -17,6 +17,14 @@ import sqlalchemy.ext.asyncio
|
|
|
17
17
|
import _
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
def group_args(table, indexes, uniques):
|
|
21
|
+
'''__table_args__ for named groups of columns, {group: [column, ...]}, as
|
|
22
|
+
composite indexes and unique constraints'''
|
|
23
|
+
args = [sqlalchemy.Index(f'ix_{table}_{group}', *columns) for group,columns in indexes.items()]
|
|
24
|
+
args += [sqlalchemy.UniqueConstraint(*columns, name=f'uq_{table}_{group}') for group,columns in uniques.items()]
|
|
25
|
+
return tuple(args)
|
|
26
|
+
|
|
27
|
+
|
|
20
28
|
class Record:
|
|
21
29
|
@classmethod
|
|
22
30
|
async def _(cls, component_name, **kwds):
|
|
@@ -67,6 +67,8 @@ class Data(_.records.Record):
|
|
|
67
67
|
cls = dataclasses.dataclass(init=False, kw_only=True)(cls)
|
|
68
68
|
|
|
69
69
|
primary_key = None
|
|
70
|
+
# named groups of columns, {group: [column, ...]}
|
|
71
|
+
indexes,uniques = {},{}
|
|
70
72
|
for field in dataclasses.fields(cls):
|
|
71
73
|
# check if column should be primary key
|
|
72
74
|
is_primary_key = field.metadata.get('pkey', False)
|
|
@@ -78,6 +80,14 @@ class Data(_.records.Record):
|
|
|
78
80
|
uniq = field.metadata.get('uniq', False)
|
|
79
81
|
refs = field.metadata.get('refs', None)
|
|
80
82
|
|
|
83
|
+
# uniq('geo') and paired('geo') group columns under a name
|
|
84
|
+
if isinstance(uniq, str):
|
|
85
|
+
uniques.setdefault(uniq, []).append(field.name)
|
|
86
|
+
uniq = False
|
|
87
|
+
paired = field.metadata.get('paired', None)
|
|
88
|
+
if paired:
|
|
89
|
+
indexes.setdefault(paired, []).append(field.name)
|
|
90
|
+
|
|
81
91
|
if refs:
|
|
82
92
|
annotations[field.name] = sqlalchemy.orm.Mapped[typing.Optional[field.type]]
|
|
83
93
|
members[field.name] = sqlalchemy.orm.mapped_column(
|
|
@@ -102,6 +112,9 @@ class Data(_.records.Record):
|
|
|
102
112
|
init=False,
|
|
103
113
|
)
|
|
104
114
|
|
|
115
|
+
if indexes or uniques:
|
|
116
|
+
members['__table_args__'] = _.records.group_args(name, indexes, uniques)
|
|
117
|
+
|
|
105
118
|
if primary_key is None:
|
|
106
119
|
primary_key = f'{name}_id'
|
|
107
120
|
members[primary_key] = sqlalchemy.orm.mapped_column(
|
|
@@ -140,7 +153,8 @@ class Data(_.records.Record):
|
|
|
140
153
|
return
|
|
141
154
|
|
|
142
155
|
types = [data_handler] if data_handler else [_.records.HandlerInterface]
|
|
143
|
-
|
|
156
|
+
# custom handlers get the JSON writing of the _ base handler
|
|
157
|
+
types.append(_.handlers.Handler)
|
|
144
158
|
|
|
145
159
|
# served at the record name, whichever handler class is used
|
|
146
160
|
handler_name = data_handler.__name__ if data_handler else name
|
|
@@ -225,7 +239,13 @@ class DataContainer(_.Container):
|
|
|
225
239
|
|
|
226
240
|
@staticmethod
|
|
227
241
|
def uniq(group=None, arg=dataclasses.MISSING):
|
|
228
|
-
|
|
242
|
+
'''a unique column, or with a group name a unique group of columns'''
|
|
243
|
+
return dataclasses.field(metadata={'uniq':group or True})
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def paired(group):
|
|
247
|
+
'''columns given the same group name share one composite index'''
|
|
248
|
+
return dataclasses.field(metadata={'paired':group})
|
|
229
249
|
|
|
230
250
|
@staticmethod
|
|
231
251
|
def refs(table_column):
|