underscorepy 0.6.4__tar.gz → 0.9.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/PKG-INFO +85 -0
- underscorepy-0.9.0/README.md +48 -0
- underscorepy-0.9.0/_/__init__.py +46 -0
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/application.py +159 -102
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/auth.py +65 -7
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/caches/__init__.py +9 -9
- underscorepy-0.9.0/_/caches/dbcache.py +140 -0
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/caches/memory.py +22 -16
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/caches/redis.py +18 -9
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/components.py +21 -14
- underscorepy-0.9.0/_/databases/__init__.py +282 -0
- underscorepy-0.9.0/_/databases/postgres.py +38 -0
- underscorepy-0.9.0/_/databases/sqlite.py +78 -0
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/handlers.py +6 -1
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/logins/__init__.py +25 -15
- underscorepy-0.9.0/_/logins/dblogin.py +230 -0
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/logins/facebook.py +1 -1
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/logins/gitlab.py +13 -4
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/logins/google.py +2 -2
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/logins/htpasswd.py +1 -9
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/logins/oauth2.py +3 -3
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/logins/slap.py +2 -6
- underscorepy-0.9.0/_/records/Protobuf.proto +21 -0
- underscorepy-0.9.0/_/records/__init__.py +186 -0
- underscorepy-0.9.0/_/records/data.py +232 -0
- underscorepy-0.9.0/_/records/protobuf.py +496 -0
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/settings.py +46 -36
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/supports/__init__.py +5 -6
- underscorepy-0.9.0/_/supports/nginx/__init__.py +78 -0
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/supports/nginx/nginx.conf +5 -5
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/supports/systemd/__init__.py +13 -7
- underscorepy-0.9.0/_/test.py +20 -0
- underscorepy-0.9.0/_/utils.py +85 -0
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/version.py +1 -1
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/websockets.py +11 -3
- underscorepy-0.9.0/pyproject.toml +49 -0
- underscorepy-0.9.0/setup.py +6 -0
- underscorepy-0.9.0/tests/test_application.py +80 -0
- underscorepy-0.9.0/tests/test_auth.py +124 -0
- underscorepy-0.9.0/tests/test_components.py +117 -0
- underscorepy-0.9.0/tests/test_migrations.py +188 -0
- underscorepy-0.9.0/tests/test_protobuf.py +408 -0
- underscorepy-0.9.0/tests/test_records.py +169 -0
- underscorepy-0.9.0/tests/test_settings.py +40 -0
- underscorepy-0.9.0/underscorepy.egg-info/PKG-INFO +85 -0
- {underscorepy-0.6.4 → underscorepy-0.9.0}/underscorepy.egg-info/SOURCES.txt +9 -13
- underscorepy-0.9.0/underscorepy.egg-info/requires.txt +28 -0
- underscorepy-0.6.4/MANIFEST.in +0 -2
- underscorepy-0.6.4/PKG-INFO +0 -23
- underscorepy-0.6.4/README.md +0 -7
- underscorepy-0.6.4/_/__init__.py +0 -32
- underscorepy-0.6.4/_/__pycache__/__init__.cpython-312.pyc +0 -0
- underscorepy-0.6.4/_/__pycache__/application.cpython-312.pyc +0 -0
- underscorepy-0.6.4/_/__pycache__/auth.cpython-312.pyc +0 -0
- underscorepy-0.6.4/_/__pycache__/components.cpython-312.pyc +0 -0
- underscorepy-0.6.4/_/__pycache__/handlers.cpython-312.pyc +0 -0
- underscorepy-0.6.4/_/__pycache__/settings.cpython-312.pyc +0 -0
- underscorepy-0.6.4/_/__pycache__/utils.cpython-312.pyc +0 -0
- underscorepy-0.6.4/_/__pycache__/version.cpython-312.pyc +0 -0
- underscorepy-0.6.4/_/caches/dbcache.py +0 -117
- underscorepy-0.6.4/_/databases/__init__.py +0 -178
- underscorepy-0.6.4/_/databases/postgres.py +0 -97
- underscorepy-0.6.4/_/databases/sqlite.py +0 -151
- underscorepy-0.6.4/_/logins/dblogin.py +0 -194
- underscorepy-0.6.4/_/records/Protobuf.proto +0 -19
- underscorepy-0.6.4/_/records/Protobuf_pb2.py +0 -35
- underscorepy-0.6.4/_/records/__init__.py +0 -197
- underscorepy-0.6.4/_/records/__pycache__/Protobuf_pb2.cpython-312.pyc +0 -0
- underscorepy-0.6.4/_/records/__pycache__/__init__.cpython-312.pyc +0 -0
- underscorepy-0.6.4/_/records/__pycache__/protobuf.cpython-312.pyc +0 -0
- underscorepy-0.6.4/_/records/data.py +0 -186
- underscorepy-0.6.4/_/records/protobuf.py +0 -149
- underscorepy-0.6.4/_/supports/nginx/__init__.py +0 -58
- underscorepy-0.6.4/_/utils.py +0 -99
- underscorepy-0.6.4/setup.py +0 -29
- underscorepy-0.6.4/underscorepy.egg-info/PKG-INFO +0 -23
- underscorepy-0.6.4/underscorepy.egg-info/requires.txt +0 -1
- {underscorepy-0.6.4 → underscorepy-0.9.0}/LICENSE +0 -0
- {underscorepy-0.6.4 → underscorepy-0.9.0}/_/supports/systemd/systemd.service +0 -0
- {underscorepy-0.6.4 → underscorepy-0.9.0}/setup.cfg +0 -0
- {underscorepy-0.6.4 → underscorepy-0.9.0}/underscorepy.egg-info/dependency_links.txt +0 -0
- {underscorepy-0.6.4 → underscorepy-0.9.0}/underscorepy.egg-info/not-zip-safe +0 -0
- {underscorepy-0.6.4 → underscorepy-0.9.0}/underscorepy.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: underscorepy
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: _.py library
|
|
5
|
+
Author-email: Matthew Shaw <mshaw.cx@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://shaw.cx/_.py
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: tornado>=6.3
|
|
17
|
+
Requires-Dist: sqlalchemy[asyncio]>=2
|
|
18
|
+
Provides-Extra: postgres
|
|
19
|
+
Requires-Dist: asyncpg; extra == "postgres"
|
|
20
|
+
Provides-Extra: sqlite
|
|
21
|
+
Requires-Dist: aiosqlite; extra == "sqlite"
|
|
22
|
+
Provides-Extra: redis
|
|
23
|
+
Requires-Dist: redis>=5.0.1; extra == "redis"
|
|
24
|
+
Provides-Extra: protobuf
|
|
25
|
+
Requires-Dist: protobuf>=5.26; extra == "protobuf"
|
|
26
|
+
Provides-Extra: ldap
|
|
27
|
+
Requires-Dist: python-ldap; extra == "ldap"
|
|
28
|
+
Provides-Extra: migrations
|
|
29
|
+
Requires-Dist: alembic; extra == "migrations"
|
|
30
|
+
Provides-Extra: test
|
|
31
|
+
Requires-Dist: pytest; extra == "test"
|
|
32
|
+
Requires-Dist: aiosqlite; extra == "test"
|
|
33
|
+
Requires-Dist: alembic; extra == "test"
|
|
34
|
+
Provides-Extra: all
|
|
35
|
+
Requires-Dist: underscorepy[ldap,migrations,postgres,protobuf,redis,sqlite]; extra == "all"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
underscore.py
|
|
40
|
+
-------------
|
|
41
|
+
|
|
42
|
+
```INI
|
|
43
|
+
[myapp]
|
|
44
|
+
address = 0.0.0.0
|
|
45
|
+
port = 8080
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```Python
|
|
49
|
+
import _
|
|
50
|
+
import _.websockets
|
|
51
|
+
|
|
52
|
+
class Echo(_.websockets.WebSocket):
|
|
53
|
+
def on_message(self, msg):
|
|
54
|
+
print('Echo:', msg)
|
|
55
|
+
self.write_message(msg)
|
|
56
|
+
|
|
57
|
+
class MyApp(_.Application):
|
|
58
|
+
def initialize(self):
|
|
59
|
+
self.patterns = [
|
|
60
|
+
( r'/ws', Echo ),
|
|
61
|
+
( r'/([a-z]*)', _.handlers.Template ),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
MyApp.main()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Look at the skeleton directory for a more complete example application.
|
|
68
|
+
|
|
69
|
+
Schema changes
|
|
70
|
+
--------------
|
|
71
|
+
|
|
72
|
+
Tables for records are created when the database is empty. After that, each
|
|
73
|
+
start compares the models (for example your `.proto` messages) with the
|
|
74
|
+
database and refuses to run if they differ:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
ERROR Database "store" differs from the schema:
|
|
78
|
+
ALTER TABLE "Reports_multiple" ADD COLUMN _position INTEGER
|
|
79
|
+
Run with --store-migrate to apply them
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`--<database>-migrate` applies changes that can't lose data. Drops and type
|
|
83
|
+
changes are printed as SQL to run by hand. This needs `alembic`
|
|
84
|
+
(`pip install underscorepy[migrations]`); set `check_schema = false` in the
|
|
85
|
+
database's section to skip the check.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
|
|
2
|
+
underscore.py
|
|
3
|
+
-------------
|
|
4
|
+
|
|
5
|
+
```INI
|
|
6
|
+
[myapp]
|
|
7
|
+
address = 0.0.0.0
|
|
8
|
+
port = 8080
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```Python
|
|
12
|
+
import _
|
|
13
|
+
import _.websockets
|
|
14
|
+
|
|
15
|
+
class Echo(_.websockets.WebSocket):
|
|
16
|
+
def on_message(self, msg):
|
|
17
|
+
print('Echo:', msg)
|
|
18
|
+
self.write_message(msg)
|
|
19
|
+
|
|
20
|
+
class MyApp(_.Application):
|
|
21
|
+
def initialize(self):
|
|
22
|
+
self.patterns = [
|
|
23
|
+
( r'/ws', Echo ),
|
|
24
|
+
( r'/([a-z]*)', _.handlers.Template ),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
MyApp.main()
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Look at the skeleton directory for a more complete example application.
|
|
31
|
+
|
|
32
|
+
Schema changes
|
|
33
|
+
--------------
|
|
34
|
+
|
|
35
|
+
Tables for records are created when the database is empty. After that, each
|
|
36
|
+
start compares the models (for example your `.proto` messages) with the
|
|
37
|
+
database and refuses to run if they differ:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
ERROR Database "store" differs from the schema:
|
|
41
|
+
ALTER TABLE "Reports_multiple" ADD COLUMN _position INTEGER
|
|
42
|
+
Run with --store-migrate to apply them
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`--<database>-migrate` applies changes that can't lose data. Drops and type
|
|
46
|
+
changes are printed as SQL to run by hand. This needs `alembic`
|
|
47
|
+
(`pip install underscorepy[migrations]`); set `check_schema = false` in the
|
|
48
|
+
database's section to skip the check.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#
|
|
2
|
+
# (c) 2015-2024 Matthew Shaw
|
|
3
|
+
#
|
|
4
|
+
# Authors
|
|
5
|
+
# =======
|
|
6
|
+
# Matthew Shaw <mshaw.cx@gmail.com>
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
import collections
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from .utils import *
|
|
13
|
+
|
|
14
|
+
from . import version
|
|
15
|
+
from . import settings
|
|
16
|
+
from . import auth
|
|
17
|
+
from . import handlers
|
|
18
|
+
from . import components
|
|
19
|
+
|
|
20
|
+
from .application import Application,WebApplication
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class error(Exception):
|
|
24
|
+
'''generic error class for throwing exceptions'''
|
|
25
|
+
|
|
26
|
+
def __init__(self, fmt, *args):
|
|
27
|
+
self.message = fmt % args
|
|
28
|
+
|
|
29
|
+
def __str__(self):
|
|
30
|
+
return self.message
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Container(collections.UserDict):
|
|
34
|
+
'''helper class to hold the loadable components'''
|
|
35
|
+
def __getattr__(self, name):
|
|
36
|
+
try:
|
|
37
|
+
return self[name]
|
|
38
|
+
except KeyError:
|
|
39
|
+
raise AttributeError(name) from None
|
|
40
|
+
|
|
41
|
+
# placeholder for comonents
|
|
42
|
+
caches = Container()
|
|
43
|
+
databases = Container()
|
|
44
|
+
logins = Container()
|
|
45
|
+
records = Container()
|
|
46
|
+
supports = Container()
|
|
@@ -8,10 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import asyncio
|
|
10
10
|
import logging
|
|
11
|
-
import os
|
|
12
11
|
import signal
|
|
13
|
-
import socket
|
|
14
|
-
import sys
|
|
15
12
|
import traceback
|
|
16
13
|
|
|
17
14
|
import tornado.web
|
|
@@ -19,29 +16,27 @@ import tornado.web
|
|
|
19
16
|
import _
|
|
20
17
|
|
|
21
18
|
|
|
22
|
-
class Application
|
|
19
|
+
class Application:
|
|
23
20
|
@classmethod
|
|
24
21
|
def main(cls, ns=None):
|
|
25
22
|
_.application = self = cls()
|
|
26
23
|
try:
|
|
27
|
-
asyncio.run(self.
|
|
24
|
+
asyncio.run(self._async_main(ns))
|
|
28
25
|
except _.error as e:
|
|
29
26
|
if _.args.debug:
|
|
30
27
|
traceback.print_tb(e.__traceback__)
|
|
31
28
|
logging.error('%s', e)
|
|
32
29
|
|
|
33
|
-
async def
|
|
34
|
-
self.
|
|
35
|
-
self._login_patterns = []
|
|
36
|
-
|
|
37
|
-
self.patterns = []
|
|
38
|
-
self.settings = {}
|
|
39
|
-
|
|
40
|
-
self.loop = asyncio.get_event_loop()
|
|
30
|
+
async def _async_main(self, ns):
|
|
31
|
+
self.loop = asyncio.get_running_loop()
|
|
41
32
|
self._stop_event = asyncio.Event()
|
|
42
33
|
|
|
43
|
-
|
|
44
|
-
|
|
34
|
+
for signum in (signal.SIGINT, signal.SIGTERM):
|
|
35
|
+
try:
|
|
36
|
+
self.loop.add_signal_handler(signum, self.__signalHandler, signum, None)
|
|
37
|
+
except NotImplementedError:
|
|
38
|
+
# Windows event loops can't add signal handlers
|
|
39
|
+
signal.signal(signum, self.__signalHandler)
|
|
45
40
|
|
|
46
41
|
name = self.__class__.__name__.lower()
|
|
47
42
|
try:
|
|
@@ -53,18 +48,9 @@ class Application(tornado.web.Application):
|
|
|
53
48
|
logging.error('%s', e)
|
|
54
49
|
self.stop()
|
|
55
50
|
|
|
56
|
-
if not self._stop_event.is_set():
|
|
57
|
-
for name,component in _.logins.items():
|
|
58
|
-
try:
|
|
59
|
-
await _.wait(component.args(name))
|
|
60
|
-
except _.error as e:
|
|
61
|
-
logging.error('%s', e)
|
|
62
|
-
self.stop()
|
|
63
|
-
break
|
|
64
|
-
|
|
65
51
|
if not self._stop_event.is_set():
|
|
66
52
|
try:
|
|
67
|
-
await self.
|
|
53
|
+
await self._async_init()
|
|
68
54
|
except _.error as e:
|
|
69
55
|
logging.error('%s', e)
|
|
70
56
|
|
|
@@ -74,84 +60,24 @@ class Application(tornado.web.Application):
|
|
|
74
60
|
for name,component in _.databases.items():
|
|
75
61
|
await component.close()
|
|
76
62
|
|
|
77
|
-
async def
|
|
78
|
-
# check if a sessions cache was specified
|
|
79
|
-
_.sessions = _.config.get(_.name, 'sessions', fallback=None)
|
|
80
|
-
if _.sessions:
|
|
81
|
-
logging.debug('Sessions cache: %s', _.sessions)
|
|
82
|
-
try:
|
|
83
|
-
_.sessions = _.caches[_.sessions]
|
|
84
|
-
except KeyError:
|
|
85
|
-
raise _.error('Unknown sessions cache instance: %s', _.sessions)
|
|
86
|
-
|
|
87
|
-
self.settings['static_path'] = _.paths('static')
|
|
88
|
-
self.settings['template_path'] = _.paths('templates')
|
|
89
|
-
self.settings['debug'] = _.args.debug
|
|
90
|
-
|
|
63
|
+
async def _async_init(self, **kwds):
|
|
91
64
|
# call the underscore application's entry point
|
|
92
65
|
try:
|
|
93
66
|
await _.wait(self.initialize())
|
|
94
67
|
except NotImplementedError:
|
|
95
68
|
logging.warning('No "initialize" function defined')
|
|
96
69
|
|
|
97
|
-
|
|
98
|
-
self.settings['cookie_secret'] = await self.cookie_secret()
|
|
99
|
-
|
|
100
|
-
patterns = list(self._records_patterns)
|
|
101
|
-
|
|
102
|
-
if self._login_patterns:
|
|
103
|
-
self._login_patterns += [
|
|
104
|
-
( r'/login', _.logins.LoginPage ),
|
|
105
|
-
( r'/logout', _.logins.Logout ),
|
|
106
|
-
]
|
|
107
|
-
self.settings['login_url'] = '/login'
|
|
108
|
-
patterns += self._login_patterns
|
|
109
|
-
patterns += self.patterns
|
|
110
|
-
|
|
111
|
-
patterns.append(
|
|
112
|
-
( r'/(favicon.ico)', tornado.web.StaticFileHandler, {'path':''}),
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
if _.args.debug:
|
|
116
|
-
for (pattern,cls,*params) in patterns:
|
|
117
|
-
handler = f'{cls.__module__}.{cls.__name__}'
|
|
118
|
-
logging.debug('%-32s %s %s', pattern, handler, params[0] if params else '')
|
|
119
|
-
|
|
120
|
-
await self.__listen(patterns)
|
|
70
|
+
# block until the stop event is set
|
|
121
71
|
await self._stop_event.wait()
|
|
72
|
+
# call clean-up code
|
|
122
73
|
await _.wait(self.on_stop())
|
|
123
74
|
|
|
124
|
-
async def __listen(self, patterns, **kwds):
|
|
125
|
-
# call the Tornado Application init here to give children a chance
|
|
126
|
-
# to initialize patterns and settings
|
|
127
|
-
super().__init__(patterns, **self.settings)
|
|
128
|
-
|
|
129
|
-
if 'xheaders' not in kwds:
|
|
130
|
-
kwds['xheaders'] = True
|
|
131
|
-
|
|
132
|
-
try:
|
|
133
|
-
self.listen(_.args.port, _.args.address, **kwds)
|
|
134
|
-
except Exception as e:
|
|
135
|
-
raise _.error('%s', e) from None
|
|
136
|
-
|
|
137
|
-
logging.info('Listening on %s:%d', _.args.address, _.args.port)
|
|
138
|
-
|
|
139
|
-
def _record_handler(self, name, cls):
|
|
140
|
-
self._records_patterns.append(
|
|
141
|
-
(f'/{name}/{cls._name}(?:/(.*))?', cls)
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
def _login_handler(self, name, cls):
|
|
145
|
-
self._login_patterns.append(
|
|
146
|
-
(f'/{name}/{cls._name}', cls)
|
|
147
|
-
)
|
|
148
|
-
|
|
149
75
|
async def initialize(self):
|
|
150
|
-
'underscore
|
|
76
|
+
'''underscore app entry point'''
|
|
151
77
|
raise NotImplementedError
|
|
152
78
|
|
|
153
79
|
async def logging(self):
|
|
154
|
-
'underscore apps can override or extend this function'
|
|
80
|
+
'''underscore apps can override or extend this function'''
|
|
155
81
|
|
|
156
82
|
# add the handlers to the logger
|
|
157
83
|
if _.config.getboolean(_.name, 'logging', fallback=False):
|
|
@@ -167,14 +93,12 @@ class Application(tornado.web.Application):
|
|
|
167
93
|
root_logger = logging.getLogger()
|
|
168
94
|
root_logger.addHandler(file_logger)
|
|
169
95
|
|
|
170
|
-
async def
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
'underscore apps should override this function if a login is specified'
|
|
177
|
-
raise NotImplementedError
|
|
96
|
+
async def sleep(self, _timeout):
|
|
97
|
+
try:
|
|
98
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=_timeout)
|
|
99
|
+
except asyncio.TimeoutError:
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
178
102
|
|
|
179
103
|
def task(self, fn, *args, **kwds):
|
|
180
104
|
async def _task():
|
|
@@ -182,7 +106,7 @@ class Application(tornado.web.Application):
|
|
|
182
106
|
return asyncio.create_task(_task())
|
|
183
107
|
|
|
184
108
|
def periodic(self, _timeout, fn, *args, **kwds):
|
|
185
|
-
'run a function or coroutine on a recurring basis'
|
|
109
|
+
'''run a function or coroutine on a recurring basis'''
|
|
186
110
|
async def _periodic():
|
|
187
111
|
while True:
|
|
188
112
|
# bail if the stop event is set
|
|
@@ -190,10 +114,10 @@ class Application(tornado.web.Application):
|
|
|
190
114
|
try:
|
|
191
115
|
await asyncio.wait_for(self._stop_event.wait(), timeout=_timeout)
|
|
192
116
|
break
|
|
193
|
-
except asyncio.TimeoutError
|
|
117
|
+
except asyncio.TimeoutError:
|
|
194
118
|
pass
|
|
195
119
|
try:
|
|
196
|
-
|
|
120
|
+
await _.wait(fn(*args, **kwds))
|
|
197
121
|
except Exception as e:
|
|
198
122
|
logging.exception(e)
|
|
199
123
|
return asyncio.create_task(_periodic())
|
|
@@ -205,8 +129,16 @@ class Application(tornado.web.Application):
|
|
|
205
129
|
def on_stop(self):
|
|
206
130
|
pass
|
|
207
131
|
|
|
132
|
+
def _record_handler(self, component, cls):
|
|
133
|
+
'''records and caches register REST handlers here, only a WebApplication serves them'''
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
def _login_handler(self, component, cls):
|
|
137
|
+
'''logins register their handlers here, only a WebApplication serves them'''
|
|
138
|
+
pass
|
|
139
|
+
|
|
208
140
|
def __signalHandler(self, signum, frame):
|
|
209
|
-
'handle signals in a thread-safe way'
|
|
141
|
+
'''handle signals in a thread-safe way, frame is None unless on Windows'''
|
|
210
142
|
signame = signal.Signals(signum).name
|
|
211
143
|
handler = getattr(self, f'on_{signame.lower()}', None)
|
|
212
144
|
if handler:
|
|
@@ -219,3 +151,128 @@ class Application(tornado.web.Application):
|
|
|
219
151
|
# and print newline after ^C on terminals
|
|
220
152
|
def on_sigint(self, signum, frame):
|
|
221
153
|
print()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class WebApplication(Application, tornado.web.Application):
|
|
157
|
+
async def _async_main(self, ns):
|
|
158
|
+
self._records_patterns = []
|
|
159
|
+
self._login_patterns = []
|
|
160
|
+
self.patterns = []
|
|
161
|
+
self.settings = {}
|
|
162
|
+
|
|
163
|
+
_.argparser.add_argument('--address', '-a',
|
|
164
|
+
metavar='<address>',
|
|
165
|
+
help = 'Interface to bind to')
|
|
166
|
+
|
|
167
|
+
_.argparser.add_argument('--port', '-p',
|
|
168
|
+
metavar='<port>',
|
|
169
|
+
type=int,
|
|
170
|
+
help='Port to listen on')
|
|
171
|
+
|
|
172
|
+
await super(WebApplication, self)._async_main(ns)
|
|
173
|
+
|
|
174
|
+
async def _async_init(self, **kwds):
|
|
175
|
+
_.sessions = _.config.get(_.name, 'sessions', fallback=None)
|
|
176
|
+
if _.sessions:
|
|
177
|
+
logging.debug('Sessions cache: %s', _.sessions)
|
|
178
|
+
try:
|
|
179
|
+
_.sessions = _.caches[_.sessions]
|
|
180
|
+
except KeyError:
|
|
181
|
+
raise _.error('Unknown sessions cache instance: %s', _.sessions)
|
|
182
|
+
|
|
183
|
+
self.settings['static_path'] = _.paths('static')
|
|
184
|
+
self.settings['template_path'] = _.paths('templates')
|
|
185
|
+
self.settings['debug'] = _.args.debug
|
|
186
|
+
if 'cookie_secret' not in self.settings:
|
|
187
|
+
self.settings['cookie_secret'] = await self.cookie_secret()
|
|
188
|
+
|
|
189
|
+
# call the underscore application's entry point
|
|
190
|
+
try:
|
|
191
|
+
await _.wait(self.initialize())
|
|
192
|
+
except NotImplementedError:
|
|
193
|
+
logging.warning('No "initialize" function defined')
|
|
194
|
+
|
|
195
|
+
patterns = list(self._records_patterns)
|
|
196
|
+
|
|
197
|
+
if self._login_patterns:
|
|
198
|
+
patterns.extend(self._login_patterns)
|
|
199
|
+
patterns.extend([
|
|
200
|
+
( r'/login', _.logins.LoginPage ),
|
|
201
|
+
( r'/logout', _.logins.Logout ),
|
|
202
|
+
])
|
|
203
|
+
self.settings['login_url'] = '/login'
|
|
204
|
+
|
|
205
|
+
patterns.extend(self.patterns)
|
|
206
|
+
|
|
207
|
+
# prepend prefix specified in the ini file, useful for nginx reverse proxy
|
|
208
|
+
prefix = _.config.get(_.name, 'prefix', fallback='')
|
|
209
|
+
if prefix:
|
|
210
|
+
if not prefix[0] == '/':
|
|
211
|
+
prefix = '/' + prefix
|
|
212
|
+
patterns = [(f'{prefix}{r[0]}',) + tuple(r[1:]) for r in patterns]
|
|
213
|
+
else:
|
|
214
|
+
patterns.append(
|
|
215
|
+
( r'/(favicon.ico)', tornado.web.StaticFileHandler, {'path':''}),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if _.args.debug:
|
|
219
|
+
for (pattern,cls,*params) in patterns:
|
|
220
|
+
handler = f'{cls.__module__}.{cls.__name__}'
|
|
221
|
+
logging.debug('%-32s %s %s', pattern, handler, params[0] if params else '')
|
|
222
|
+
|
|
223
|
+
await self.__listen(patterns)
|
|
224
|
+
|
|
225
|
+
# block until the stop event is set
|
|
226
|
+
await self._stop_event.wait()
|
|
227
|
+
# call clean-up code
|
|
228
|
+
await _.wait(self.on_stop())
|
|
229
|
+
|
|
230
|
+
async def __listen(self, patterns, **kwds):
|
|
231
|
+
'''call the Tornado Application init here to give children a chance
|
|
232
|
+
to initialize patterns and settings'''
|
|
233
|
+
super().__init__(patterns, **self.settings)
|
|
234
|
+
|
|
235
|
+
if not _.args.address:
|
|
236
|
+
_.args.address = _.config.get(_.name, 'address', fallback='127.0.0.1')
|
|
237
|
+
|
|
238
|
+
if not _.args.port:
|
|
239
|
+
_.args.port = _.config.getint(_.name, 'port', fallback=8080)
|
|
240
|
+
|
|
241
|
+
if 'xheaders' not in kwds:
|
|
242
|
+
kwds['xheaders'] = True
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
self.listen(_.args.port, _.args.address, **kwds)
|
|
246
|
+
logging.info('Listening on %s:%d', _.args.address, _.args.port)
|
|
247
|
+
except Exception as e:
|
|
248
|
+
raise _.error('%s', e) from None
|
|
249
|
+
|
|
250
|
+
def _record_handler(self, component, cls):
|
|
251
|
+
'''called from _.records component to add handler'''
|
|
252
|
+
self._records_patterns.append(
|
|
253
|
+
(f'/{component}/{cls._component}(?:/(.*))?', cls)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def _login_handler(self, component, cls):
|
|
257
|
+
'''called from _.logins component to add handler'''
|
|
258
|
+
self._login_patterns.append(
|
|
259
|
+
(f'/{component}/{cls._component}', cls)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
async def cookie_secret(self):
|
|
263
|
+
'''underscore apps can override this function'''
|
|
264
|
+
if _.sessions is not None:
|
|
265
|
+
return await _.wait(_.sessions.cookie_secret())
|
|
266
|
+
|
|
267
|
+
async def on_login_success(self, handler, user, *args, **kwds):
|
|
268
|
+
'''underscore apps should override this function if a login component is specified'''
|
|
269
|
+
raise NotImplementedError
|
|
270
|
+
|
|
271
|
+
async def on_login_failure(self, component, *args, **kwds):
|
|
272
|
+
'''underscore apps should override this function if a login component is specified'''
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
async def on_logout(self, sessions_id):
|
|
276
|
+
'''underscore apps should override this function if a login component is specified'''
|
|
277
|
+
pass
|
|
278
|
+
|
|
@@ -10,6 +10,8 @@ import base64
|
|
|
10
10
|
import binascii
|
|
11
11
|
import functools
|
|
12
12
|
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import os
|
|
13
15
|
import urllib.parse
|
|
14
16
|
|
|
15
17
|
import _
|
|
@@ -67,6 +69,13 @@ current_user = filter_user(lambda current_user: True)
|
|
|
67
69
|
# control over access to component handlers
|
|
68
70
|
protected = filter_user(lambda current_user: True)
|
|
69
71
|
|
|
72
|
+
# default record access is unprotected, override before components are loaded
|
|
73
|
+
# to require a session for the _.records handlers, for example:
|
|
74
|
+
#
|
|
75
|
+
# _.auth.records = _.auth.filter_user(lambda session: session)
|
|
76
|
+
#
|
|
77
|
+
records = lambda fn: fn
|
|
78
|
+
|
|
70
79
|
|
|
71
80
|
# implement old school user/name password prompt
|
|
72
81
|
# useful for command-line tools to pass as part of the URL
|
|
@@ -76,8 +85,11 @@ def basic(realm='Authentication'):
|
|
|
76
85
|
async def wrapper(self, *args, **kwds):
|
|
77
86
|
auth = self.request.headers.get('Authorization', '')
|
|
78
87
|
if auth.startswith('Basic '):
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
try:
|
|
89
|
+
auth = binascii.a2b_base64(auth[6:]).decode('utf-8')
|
|
90
|
+
username,password = auth.split(':', 1)
|
|
91
|
+
except (binascii.Error,UnicodeDecodeError,ValueError):
|
|
92
|
+
raise _.HTTPError(400) from None
|
|
81
93
|
component = _.config.get(_.name, 'basic', fallback=None)
|
|
82
94
|
if not component:
|
|
83
95
|
raise _.HTTPError(500, 'No component specified for basic auth')
|
|
@@ -87,19 +99,65 @@ def basic(realm='Authentication'):
|
|
|
87
99
|
raise _.HTTPError(500, 'Invalid component specified for basic auth')
|
|
88
100
|
success = await login.check(username, password)
|
|
89
101
|
if success:
|
|
90
|
-
return method(self, *args, **kwds)
|
|
102
|
+
return await _.wait(method(self, *args, **kwds))
|
|
91
103
|
self.set_status(401)
|
|
92
|
-
self.set_header('WWW-Authenticate', f'Basic realm={realm}')
|
|
104
|
+
self.set_header('WWW-Authenticate', f'Basic realm="{realm}"')
|
|
93
105
|
self.finish()
|
|
94
106
|
return wrapper
|
|
95
107
|
return basic_auth
|
|
96
108
|
|
|
97
109
|
|
|
110
|
+
# scrypt parameters for new hashes, stored alongside each hash so they can be
|
|
111
|
+
# raised later without invalidating existing passwords
|
|
112
|
+
SCRYPT_N = 2 ** 15
|
|
113
|
+
SCRYPT_R = 8
|
|
114
|
+
SCRYPT_P = 1
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _scrypt(password, salt, n, r, p):
|
|
118
|
+
return hashlib.scrypt(password.encode('utf-8'), salt=salt,
|
|
119
|
+
n=n, r=r, p=p, maxmem=128 * r * (n + p + 2), dklen=64)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def hash_password(password):
|
|
123
|
+
'''return a salted scrypt hash formatted as scrypt$n$r$p$salt$hash'''
|
|
124
|
+
salt = os.urandom(16)
|
|
125
|
+
digest = _scrypt(password, salt, SCRYPT_N, SCRYPT_R, SCRYPT_P)
|
|
126
|
+
salt = base64.b64encode(salt).decode('ascii')
|
|
127
|
+
digest = base64.b64encode(digest).decode('ascii')
|
|
128
|
+
return f'scrypt${SCRYPT_N}${SCRYPT_R}${SCRYPT_P}${salt}${digest}'
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def verify_password(password, stored, username=''):
|
|
132
|
+
'''check a password against a stored hash, username is only used by legacy hashes'''
|
|
133
|
+
if not password or not stored:
|
|
134
|
+
return False
|
|
135
|
+
if not stored.startswith('scrypt$'):
|
|
136
|
+
return hmac.compare_digest(simple_hash(username + password), stored)
|
|
137
|
+
try:
|
|
138
|
+
_scheme,n,r,p,salt,digest = stored.split('$')
|
|
139
|
+
n,r,p = int(n), int(r), int(p)
|
|
140
|
+
salt = base64.b64decode(salt)
|
|
141
|
+
digest = base64.b64decode(digest)
|
|
142
|
+
except (ValueError,binascii.Error):
|
|
143
|
+
return False
|
|
144
|
+
return hmac.compare_digest(_scrypt(password, salt, n, r, p), digest)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def needs_rehash(stored):
|
|
148
|
+
'''true for legacy hashes or hashes made with weaker parameters'''
|
|
149
|
+
if not stored or not stored.startswith('scrypt$'):
|
|
150
|
+
return True
|
|
151
|
+
try:
|
|
152
|
+
n,r,p = (int(v) for v in stored.split('$')[1:4])
|
|
153
|
+
except ValueError:
|
|
154
|
+
return True
|
|
155
|
+
return (n,r,p) != (SCRYPT_N,SCRYPT_R,SCRYPT_P)
|
|
156
|
+
|
|
157
|
+
|
|
98
158
|
def simple_hash(value):
|
|
159
|
+
'''legacy unsalted hash, only kept to verify existing passwords'''
|
|
99
160
|
value = value.encode('utf-8')
|
|
100
161
|
for i in range(99999):
|
|
101
162
|
value = hashlib.sha512(value).digest()
|
|
102
163
|
return base64.b64encode(value).decode('ascii')
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
__all__ = _.all()
|
|
@@ -11,7 +11,7 @@ import _
|
|
|
11
11
|
|
|
12
12
|
class Cache:
|
|
13
13
|
@classmethod
|
|
14
|
-
async def _(cls,
|
|
14
|
+
async def _(cls, component_name, **kwds):
|
|
15
15
|
# create a dynamic child class with kwds from the ini file
|
|
16
16
|
try:
|
|
17
17
|
members = dict(_.config['sessions'])
|
|
@@ -24,10 +24,10 @@ class Cache:
|
|
|
24
24
|
|
|
25
25
|
# instantiate the derived class
|
|
26
26
|
self = type(cls.__name__, (cls,), _.prefix(members))()
|
|
27
|
-
_.caches[
|
|
28
|
-
await self.init(
|
|
27
|
+
_.caches[component_name] = self
|
|
28
|
+
await self.init(component_name, **kwds)
|
|
29
29
|
|
|
30
|
-
async def init(self, **kwds):
|
|
30
|
+
async def init(self, component_name, **kwds):
|
|
31
31
|
pass
|
|
32
32
|
|
|
33
33
|
async def close(self):
|
|
@@ -37,12 +37,12 @@ class Cache:
|
|
|
37
37
|
raise NotImplementedError
|
|
38
38
|
|
|
39
39
|
def save_session(self, session):
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
def load_session(self, session_id):
|
|
43
|
+
raise NotImplementedError
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
def clear_session(self, session_id):
|
|
46
46
|
raise NotImplementedError
|
|
47
47
|
|
|
48
48
|
|