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
_/records/__init__.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#
|
|
2
|
+
# (c) 2015-2023 Matthew Shaw
|
|
3
|
+
#
|
|
4
|
+
# Authors
|
|
5
|
+
# =======
|
|
6
|
+
# Matthew Shaw <mshaw.cx@gmail.com>
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import importlib
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
import sqlalchemy
|
|
15
|
+
import sqlalchemy.ext.asyncio
|
|
16
|
+
|
|
17
|
+
import _
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Record:
|
|
21
|
+
@classmethod
|
|
22
|
+
async def _(cls, component_name, **kwds):
|
|
23
|
+
self = cls()
|
|
24
|
+
self.component_name = component_name
|
|
25
|
+
_.records[component_name] = self
|
|
26
|
+
try:
|
|
27
|
+
await self.init(**kwds)
|
|
28
|
+
except TypeError as e:
|
|
29
|
+
raise _.error('%s', e)
|
|
30
|
+
|
|
31
|
+
async def init(self, module, **kwds):
|
|
32
|
+
try:
|
|
33
|
+
imported = importlib.import_module(module)
|
|
34
|
+
except ModuleNotFoundError as e:
|
|
35
|
+
raise _.error('Unknown module: %s: %s', module, e)
|
|
36
|
+
|
|
37
|
+
database = kwds.get('database', None)
|
|
38
|
+
self.db = _.databases[database] if database else None
|
|
39
|
+
|
|
40
|
+
await _.wait(self.load(imported))
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
async def args(cls, component_name):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
def load(self, module):
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RecordsInterface:
|
|
51
|
+
'base class for _.records'
|
|
52
|
+
|
|
53
|
+
def __call__(self, **kwds):
|
|
54
|
+
for k,v in kwds.items():
|
|
55
|
+
setattr(self, k, v)
|
|
56
|
+
|
|
57
|
+
def __getitem__(self, key):
|
|
58
|
+
return getattr(self, key)
|
|
59
|
+
|
|
60
|
+
def __setitem__(self, key, value):
|
|
61
|
+
setattr(self, key, value)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def _from_dict(cls, **kwds):
|
|
65
|
+
self = cls()
|
|
66
|
+
for k,v in kwds.items():
|
|
67
|
+
setattr(self, k, v)
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def _from_json(cls, msg):
|
|
72
|
+
return cls._from_dict(**json.loads(msg))
|
|
73
|
+
|
|
74
|
+
def _as_dict(self):
|
|
75
|
+
_dict = dataclasses.asdict(self)
|
|
76
|
+
if hasattr(self, '__primary_key__'):
|
|
77
|
+
_dict[self.__primary_key__] = getattr(self, self.__primary_key__)
|
|
78
|
+
return _dict
|
|
79
|
+
|
|
80
|
+
def _as_json(self, **kwds):
|
|
81
|
+
return json.dumps(self, cls=_.JSONEncoder, separators=(',',':'), **kwds)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _delimited(records):
|
|
85
|
+
'prefix each record with its varint length, like a protobuf delimited stream'
|
|
86
|
+
packed = bytearray()
|
|
87
|
+
for record in records:
|
|
88
|
+
data = record._as_binary()
|
|
89
|
+
size = len(data)
|
|
90
|
+
while size > 0x7f:
|
|
91
|
+
packed.append((size & 0x7f) | 0x80)
|
|
92
|
+
size >>= 7
|
|
93
|
+
packed.append(size)
|
|
94
|
+
packed += data
|
|
95
|
+
return bytes(packed)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class HandlerInterface(_.handlers.Protected):
|
|
99
|
+
def initialize(self):
|
|
100
|
+
self.content_type = self.get_argument('type', 'json')
|
|
101
|
+
if self.content_type == 'json':
|
|
102
|
+
self.set_header('Content-Type', 'application/json; charset=UTF-8')
|
|
103
|
+
else:
|
|
104
|
+
self.set_header('Content-Type', 'application/octet-stream')
|
|
105
|
+
|
|
106
|
+
async def prepare(self):
|
|
107
|
+
await super().prepare()
|
|
108
|
+
# decode here as HTTPErrors raised in initialize drop the connection
|
|
109
|
+
if self.request.body:
|
|
110
|
+
try:
|
|
111
|
+
if self.content_type == 'json':
|
|
112
|
+
self.data = json.loads(self.request.body)
|
|
113
|
+
else:
|
|
114
|
+
self.data = self._record._from_binary(self.request.body)
|
|
115
|
+
except Exception:
|
|
116
|
+
raise _.HTTPError(400) from None
|
|
117
|
+
else:
|
|
118
|
+
self.data = None
|
|
119
|
+
|
|
120
|
+
def _record_from_body(self):
|
|
121
|
+
if self.data is None:
|
|
122
|
+
raise _.HTTPError(400)
|
|
123
|
+
# binary bodies are already decoded into a record
|
|
124
|
+
if self.content_type != 'json':
|
|
125
|
+
return self.data
|
|
126
|
+
try:
|
|
127
|
+
return self._record._from_dict(self.data)
|
|
128
|
+
except Exception:
|
|
129
|
+
raise _.HTTPError(400) from None
|
|
130
|
+
|
|
131
|
+
def _record_key(self, record_id):
|
|
132
|
+
# convert the URL path to the primary key column's type
|
|
133
|
+
try:
|
|
134
|
+
column = getattr(self._record, self._record.__primary_key__)
|
|
135
|
+
return column.expression.type.python_type(record_id)
|
|
136
|
+
except (AttributeError, NotImplementedError):
|
|
137
|
+
return record_id
|
|
138
|
+
except ValueError:
|
|
139
|
+
raise _.HTTPError(404) from None
|
|
140
|
+
|
|
141
|
+
@_.auth.records
|
|
142
|
+
async def get(self, record_id):
|
|
143
|
+
if not record_id:
|
|
144
|
+
records = await self._db.find(self._record)
|
|
145
|
+
if self.content_type == 'json':
|
|
146
|
+
self.write(dict(data=records))
|
|
147
|
+
else:
|
|
148
|
+
self.write(_delimited(records))
|
|
149
|
+
else:
|
|
150
|
+
record = await self._db.find_one(self._record, self._record_key(record_id))
|
|
151
|
+
if record is None:
|
|
152
|
+
raise _.HTTPError(404)
|
|
153
|
+
if self.content_type == 'json':
|
|
154
|
+
self.write(record._as_dict())
|
|
155
|
+
else:
|
|
156
|
+
self.write(record._as_binary())
|
|
157
|
+
|
|
158
|
+
@_.auth.records
|
|
159
|
+
async def post(self, record_id, record=None):
|
|
160
|
+
if record is None:
|
|
161
|
+
record = self._record_from_body()
|
|
162
|
+
try:
|
|
163
|
+
await self._db.insert(record)
|
|
164
|
+
except _.error as e:
|
|
165
|
+
raise _.HTTPError(409, e) from None
|
|
166
|
+
self.set_status(204)
|
|
167
|
+
#self.write(record._as_dict())
|
|
168
|
+
|
|
169
|
+
@_.auth.records
|
|
170
|
+
async def put(self, record_id, record=None):
|
|
171
|
+
if record is None:
|
|
172
|
+
record = self._record_from_body()
|
|
173
|
+
try:
|
|
174
|
+
await self._db.upsert(record)
|
|
175
|
+
except _.error as e:
|
|
176
|
+
raise _.HTTPError(400, e) from None
|
|
177
|
+
self.set_status(204)
|
|
178
|
+
|
|
179
|
+
@_.auth.records
|
|
180
|
+
async def delete(self, record_id):
|
|
181
|
+
if not record_id:
|
|
182
|
+
raise _.HTTPError(400)
|
|
183
|
+
count = await self._db.delete(self._record, self._record_key(record_id))
|
|
184
|
+
if not count:
|
|
185
|
+
raise _.HTTPError(404)
|
|
186
|
+
self.set_status(204)
|
_/records/data.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
|
|
2
|
+
import dataclasses
|
|
3
|
+
import functools
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
import json
|
|
7
|
+
import typing
|
|
8
|
+
|
|
9
|
+
import sqlalchemy
|
|
10
|
+
import tornado.web
|
|
11
|
+
|
|
12
|
+
import _
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Data(_.records.Record):
|
|
16
|
+
async def init(self, module, **kwds):
|
|
17
|
+
# setup the container beforehand so the data module can use data decorators
|
|
18
|
+
if hasattr(_, self.component_name):
|
|
19
|
+
raise _.error('Record name "%s" for "%s" conflicts in _ root', self.component_name, module.__name__)
|
|
20
|
+
self._container = DataContainer()
|
|
21
|
+
setattr(_, self.component_name, self._container)
|
|
22
|
+
await super().init(module, **kwds)
|
|
23
|
+
|
|
24
|
+
def load(self, module):
|
|
25
|
+
self.module_name = module.__name__
|
|
26
|
+
for name in dir(module):
|
|
27
|
+
if name.startswith('__'):
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
if name in self._container._ignore:
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
# ignore objects that are not classes
|
|
34
|
+
cls = getattr(module, name)
|
|
35
|
+
if not isinstance(cls, type(Data)):
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
# ignore classes outside of module root
|
|
39
|
+
if not cls.__module__.startswith(self.module_name):
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
if self.db and not hasattr(cls, f'_{name}__no_table'):
|
|
43
|
+
try:
|
|
44
|
+
record_type = self._data_table(name, cls)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
logging.exception('%s', e)
|
|
47
|
+
else:
|
|
48
|
+
if not dataclasses.is_dataclass(cls):
|
|
49
|
+
cls = dataclasses.dataclass(init=False, kw_only=True)(cls)
|
|
50
|
+
record_type = type(name, (DataInterface,cls), {})
|
|
51
|
+
|
|
52
|
+
self._container[name] = record_type
|
|
53
|
+
|
|
54
|
+
if not hasattr(cls, f'_{name}__no_handler'):
|
|
55
|
+
self._data_handler(name, record_type)
|
|
56
|
+
|
|
57
|
+
def _data_table(self, name, cls, parent=None, parent_key=None, parent_col=None):
|
|
58
|
+
child_tables = {}
|
|
59
|
+
annotations = {}
|
|
60
|
+
members = {
|
|
61
|
+
'__tablename__' : name,
|
|
62
|
+
'__annotations__' : annotations,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# make class a dataclass if it isn't already
|
|
66
|
+
if not dataclasses.is_dataclass(cls):
|
|
67
|
+
cls = dataclasses.dataclass(init=False, kw_only=True)(cls)
|
|
68
|
+
|
|
69
|
+
primary_key = None
|
|
70
|
+
for field in dataclasses.fields(cls):
|
|
71
|
+
# check if column should be primary key
|
|
72
|
+
is_primary_key = field.metadata.get('pkey', False)
|
|
73
|
+
if is_primary_key:
|
|
74
|
+
if primary_key:
|
|
75
|
+
raise _.error('Only one primary key can be specified')
|
|
76
|
+
primary_key = field.name
|
|
77
|
+
|
|
78
|
+
uniq = field.metadata.get('uniq', False)
|
|
79
|
+
refs = field.metadata.get('refs', None)
|
|
80
|
+
|
|
81
|
+
if refs:
|
|
82
|
+
annotations[field.name] = sqlalchemy.orm.Mapped[typing.Optional[field.type]]
|
|
83
|
+
members[field.name] = sqlalchemy.orm.mapped_column(
|
|
84
|
+
sqlalchemy.ForeignKey(refs, ondelete="CASCADE"),
|
|
85
|
+
init=False,
|
|
86
|
+
)
|
|
87
|
+
elif field.type.__module__.startswith(self.module_name):
|
|
88
|
+
ref_table_name = f'{name}_{field.name}'
|
|
89
|
+
annotations[field.name] = sqlalchemy.orm.Mapped[typing.Optional[ref_table_name]]
|
|
90
|
+
members[field.name] = sqlalchemy.orm.relationship(
|
|
91
|
+
back_populates=name,
|
|
92
|
+
lazy='joined',
|
|
93
|
+
cascade="all, delete-orphan",
|
|
94
|
+
init=False,
|
|
95
|
+
)
|
|
96
|
+
child_tables[ref_table_name] = field
|
|
97
|
+
else:
|
|
98
|
+
annotations[field.name] = typing.Optional[field.type]
|
|
99
|
+
members[field.name] = sqlalchemy.orm.mapped_column(
|
|
100
|
+
primary_key=is_primary_key,
|
|
101
|
+
unique=uniq,
|
|
102
|
+
init=False,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if primary_key is None:
|
|
106
|
+
primary_key = f'{name}_id'
|
|
107
|
+
members[primary_key] = sqlalchemy.orm.mapped_column(
|
|
108
|
+
sqlalchemy.INTEGER,
|
|
109
|
+
primary_key=True,
|
|
110
|
+
autoincrement=True,
|
|
111
|
+
init=False,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if parent and parent_key and parent_col:
|
|
115
|
+
members[f'{parent}_{parent_key}'] = sqlalchemy.orm.mapped_column(
|
|
116
|
+
sqlalchemy.ForeignKey(f'{parent}.{parent_key}', ondelete="CASCADE"),
|
|
117
|
+
init=False,
|
|
118
|
+
)
|
|
119
|
+
members[f'{parent}'] = sqlalchemy.orm.relationship(
|
|
120
|
+
parent,
|
|
121
|
+
back_populates=parent_col,
|
|
122
|
+
lazy='joined',
|
|
123
|
+
init=False,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
members['__primary_key__'] = primary_key
|
|
127
|
+
|
|
128
|
+
table_type = type(name, (DataInterface,self.db.Base,), members)
|
|
129
|
+
|
|
130
|
+
for child_table,field in child_tables.items():
|
|
131
|
+
child_type = self._data_table(child_table, field.type, parent=name, parent_key=primary_key, parent_col=field.name)
|
|
132
|
+
setattr(table_type, f'_{field.name}', child_type)
|
|
133
|
+
|
|
134
|
+
return table_type
|
|
135
|
+
|
|
136
|
+
def _data_handler(self, name, record_type):
|
|
137
|
+
data_handler = self._container._handlers.get(name)
|
|
138
|
+
# the default handler needs a table
|
|
139
|
+
if not data_handler and not hasattr(record_type, '__table__'):
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
types = [data_handler] if data_handler else [_.records.HandlerInterface]
|
|
143
|
+
types.append(tornado.web.RequestHandler)
|
|
144
|
+
|
|
145
|
+
# served at the record name, whichever handler class is used
|
|
146
|
+
handler_name = data_handler.__name__ if data_handler else name
|
|
147
|
+
record_handler = type(handler_name, tuple(types), {
|
|
148
|
+
'_component' : name,
|
|
149
|
+
'_db' : self.db,
|
|
150
|
+
'_record' : record_type,
|
|
151
|
+
'__module__' : self.module_name,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
_.application._record_handler(self.component_name, record_handler)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class DataInterface(_.records.RecordsInterface):
|
|
158
|
+
@staticmethod
|
|
159
|
+
def __dataclass(cls, msg, dst):
|
|
160
|
+
for field in dataclasses.fields(cls):
|
|
161
|
+
child_cls = getattr(cls, f'_{field.name}', None)
|
|
162
|
+
if child_cls:
|
|
163
|
+
child = child_cls()
|
|
164
|
+
DataInterface.__dataclass(child_cls, msg.get(field.name), child)
|
|
165
|
+
setattr(dst, field.name, child)
|
|
166
|
+
else:
|
|
167
|
+
if field.name in msg:
|
|
168
|
+
setattr(dst, field.name, msg[field.name])
|
|
169
|
+
return dst
|
|
170
|
+
|
|
171
|
+
def __call__(self, *args, **kwds):
|
|
172
|
+
msg = args[0] if args else kwds
|
|
173
|
+
return DataInterface.__dataclass(self, msg, self)
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def _from_dict(cls, *args, **kwds):
|
|
177
|
+
msg = args[0] if args else kwds
|
|
178
|
+
return DataInterface.__dataclass(cls, msg, cls())
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def _from_json(cls, msg):
|
|
182
|
+
return DataInterface.__dataclass(cls, json.loads(msg), cls())
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def _from_binary(cls, msg):
|
|
186
|
+
return DataInterface.__dataclass(cls, json.loads(msg), cls())
|
|
187
|
+
|
|
188
|
+
def _as_binary(self):
|
|
189
|
+
# omits an auto-generated primary key but keeps a declared one
|
|
190
|
+
_dict = dataclasses.asdict(self)
|
|
191
|
+
return json.dumps(_dict, cls=_.JSONEncoder, separators=(',',':')).encode('utf-8')
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class DataContainer(_.Container):
|
|
195
|
+
def __init__(self):
|
|
196
|
+
super().__init__()
|
|
197
|
+
self._ignore = set()
|
|
198
|
+
self._handlers = {}
|
|
199
|
+
|
|
200
|
+
# decorator for adding custom handlers for message types
|
|
201
|
+
def handles(self, _datacls):
|
|
202
|
+
def wrap(_handler):
|
|
203
|
+
self._ignore.add(_handler.__name__)
|
|
204
|
+
self._handlers[_datacls.__name__] = _handler
|
|
205
|
+
return _handler
|
|
206
|
+
return wrap
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def dump(obj):
|
|
210
|
+
return DataInterface.dump(obj)
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def no_table(cls):
|
|
214
|
+
setattr(cls, f'_{cls.__name__}__no_table', True)
|
|
215
|
+
return cls
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def no_handler(cls):
|
|
219
|
+
setattr(cls, f'_{cls.__name__}__no_handler', True)
|
|
220
|
+
return cls
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def pkey(arg=dataclasses.MISSING):
|
|
224
|
+
return dataclasses.field(metadata={'pkey':True})
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def uniq(group=None, arg=dataclasses.MISSING):
|
|
228
|
+
return dataclasses.field(metadata={'uniq':True})
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def refs(table_column):
|
|
232
|
+
return dataclasses.field(metadata={'refs':table_column})
|