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.
Files changed (56) hide show
  1. {underscorepy-0.9.0 → underscorepy-0.10.0}/PKG-INFO +1 -1
  2. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/caches/memory.py +1 -1
  3. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/caches/redis.py +1 -1
  4. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/databases/__init__.py +35 -0
  5. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/handlers.py +12 -1
  6. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/logins/__init__.py +3 -3
  7. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/logins/dblogin.py +2 -2
  8. underscorepy-0.10.0/_/records/Protobuf.proto +32 -0
  9. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/records/__init__.py +8 -0
  10. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/records/data.py +22 -2
  11. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/records/protobuf.py +273 -52
  12. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/supports/nginx/__init__.py +2 -0
  13. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/supports/nginx/nginx.conf +6 -6
  14. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/supports/systemd/__init__.py +18 -1
  15. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/supports/systemd/systemd.service +1 -0
  16. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/utils.py +18 -3
  17. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/version.py +1 -1
  18. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/websockets.py +6 -0
  19. underscorepy-0.10.0/tests/test_application.py +148 -0
  20. {underscorepy-0.9.0 → underscorepy-0.10.0}/tests/test_auth.py +21 -0
  21. {underscorepy-0.9.0 → underscorepy-0.10.0}/tests/test_components.py +82 -0
  22. underscorepy-0.10.0/tests/test_protobuf.py +718 -0
  23. {underscorepy-0.9.0 → underscorepy-0.10.0}/tests/test_records.py +24 -0
  24. {underscorepy-0.9.0 → underscorepy-0.10.0}/underscorepy.egg-info/PKG-INFO +1 -1
  25. underscorepy-0.9.0/_/records/Protobuf.proto +0 -21
  26. underscorepy-0.9.0/tests/test_application.py +0 -80
  27. underscorepy-0.9.0/tests/test_protobuf.py +0 -408
  28. {underscorepy-0.9.0 → underscorepy-0.10.0}/LICENSE +0 -0
  29. {underscorepy-0.9.0 → underscorepy-0.10.0}/README.md +0 -0
  30. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/__init__.py +0 -0
  31. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/application.py +0 -0
  32. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/auth.py +0 -0
  33. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/caches/__init__.py +0 -0
  34. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/caches/dbcache.py +0 -0
  35. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/components.py +0 -0
  36. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/databases/postgres.py +0 -0
  37. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/databases/sqlite.py +0 -0
  38. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/logins/facebook.py +0 -0
  39. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/logins/gitlab.py +0 -0
  40. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/logins/google.py +0 -0
  41. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/logins/htpasswd.py +0 -0
  42. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/logins/oauth2.py +0 -0
  43. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/logins/slap.py +0 -0
  44. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/settings.py +0 -0
  45. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/supports/__init__.py +0 -0
  46. {underscorepy-0.9.0 → underscorepy-0.10.0}/_/test.py +0 -0
  47. {underscorepy-0.9.0 → underscorepy-0.10.0}/pyproject.toml +0 -0
  48. {underscorepy-0.9.0 → underscorepy-0.10.0}/setup.cfg +0 -0
  49. {underscorepy-0.9.0 → underscorepy-0.10.0}/setup.py +0 -0
  50. {underscorepy-0.9.0 → underscorepy-0.10.0}/tests/test_migrations.py +0 -0
  51. {underscorepy-0.9.0 → underscorepy-0.10.0}/tests/test_settings.py +0 -0
  52. {underscorepy-0.9.0 → underscorepy-0.10.0}/underscorepy.egg-info/SOURCES.txt +0 -0
  53. {underscorepy-0.9.0 → underscorepy-0.10.0}/underscorepy.egg-info/dependency_links.txt +0 -0
  54. {underscorepy-0.9.0 → underscorepy-0.10.0}/underscorepy.egg-info/not-zip-safe +0 -0
  55. {underscorepy-0.9.0 → underscorepy-0.10.0}/underscorepy.egg-info/requires.txt +0 -0
  56. {underscorepy-0.9.0 → underscorepy-0.10.0}/underscorepy.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: underscorepy
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: _.py library
5
5
  Author-email: Matthew Shaw <mshaw.cx@gmail.com>
6
6
  License-Expression: MIT
@@ -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] = json.dumps(session)
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}', json.dumps(session))
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 Template(tornado.web.RequestHandler):
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(tornado.web.RequestHandler):
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(tornado.web.RequestHandler):
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(tornado.web.RequestHandler):
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 AttributeError:
36
- raise _.error('No database specified for %s', component_name)
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
- types.append(tornado.web.RequestHandler)
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
- return dataclasses.field(metadata={'uniq':True})
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):