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.
_/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})