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 ADDED
@@ -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()
_/application.py ADDED
@@ -0,0 +1,278 @@
1
+ #
2
+ # (c) 2015-2023 Matthew Shaw
3
+ #
4
+ # Authors
5
+ # =======
6
+ # Matthew Shaw <mshaw.cx@gmail.com>
7
+ #
8
+
9
+ import asyncio
10
+ import logging
11
+ import signal
12
+ import traceback
13
+
14
+ import tornado.web
15
+
16
+ import _
17
+
18
+
19
+ class Application:
20
+ @classmethod
21
+ def main(cls, ns=None):
22
+ _.application = self = cls()
23
+ try:
24
+ asyncio.run(self._async_main(ns))
25
+ except _.error as e:
26
+ if _.args.debug:
27
+ traceback.print_tb(e.__traceback__)
28
+ logging.error('%s', e)
29
+
30
+ async def _async_main(self, ns):
31
+ self.loop = asyncio.get_running_loop()
32
+ self._stop_event = asyncio.Event()
33
+
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)
40
+
41
+ name = self.__class__.__name__.lower()
42
+ try:
43
+ await _.settings.load(ns=ns, name=name)
44
+ await self.logging()
45
+ except _.error as e:
46
+ if _.args.debug:
47
+ traceback.print_tb(e.__traceback__)
48
+ logging.error('%s', e)
49
+ self.stop()
50
+
51
+ if not self._stop_event.is_set():
52
+ try:
53
+ await self._async_init()
54
+ except _.error as e:
55
+ logging.error('%s', e)
56
+
57
+ for name,component in _.caches.items():
58
+ await component.close()
59
+
60
+ for name,component in _.databases.items():
61
+ await component.close()
62
+
63
+ async def _async_init(self, **kwds):
64
+ # call the underscore application's entry point
65
+ try:
66
+ await _.wait(self.initialize())
67
+ except NotImplementedError:
68
+ logging.warning('No "initialize" function defined')
69
+
70
+ # block until the stop event is set
71
+ await self._stop_event.wait()
72
+ # call clean-up code
73
+ await _.wait(self.on_stop())
74
+
75
+ async def initialize(self):
76
+ '''underscore app entry point'''
77
+ raise NotImplementedError
78
+
79
+ async def logging(self):
80
+ '''underscore apps can override or extend this function'''
81
+
82
+ # add the handlers to the logger
83
+ if _.config.getboolean(_.name, 'logging', fallback=False):
84
+ full_path = _.paths(f'{_.name}.log')
85
+ file_logger = logging.FileHandler(full_path)
86
+ file_logger.setLevel(logging.DEBUG if _.args.debug else logging.INFO)
87
+ file_logger.setFormatter(
88
+ logging.Formatter(
89
+ fmt = '%(asctime)s %(levelname)-8s %(message)s',
90
+ datefmt = '%Y-%m-%d %H:%M:%S',
91
+ )
92
+ )
93
+ root_logger = logging.getLogger()
94
+ root_logger.addHandler(file_logger)
95
+
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
102
+
103
+ def task(self, fn, *args, **kwds):
104
+ async def _task():
105
+ await _.wait(fn(*args, **kwds))
106
+ return asyncio.create_task(_task())
107
+
108
+ def periodic(self, _timeout, fn, *args, **kwds):
109
+ '''run a function or coroutine on a recurring basis'''
110
+ async def _periodic():
111
+ while True:
112
+ # bail if the stop event is set
113
+ # otherwise run the function after the timeout occurs
114
+ try:
115
+ await asyncio.wait_for(self._stop_event.wait(), timeout=_timeout)
116
+ break
117
+ except asyncio.TimeoutError:
118
+ pass
119
+ try:
120
+ await _.wait(fn(*args, **kwds))
121
+ except Exception as e:
122
+ logging.exception(e)
123
+ return asyncio.create_task(_periodic())
124
+
125
+ def stop(self):
126
+ logging.debug('Setting stop event')
127
+ self._stop_event.set()
128
+
129
+ def on_stop(self):
130
+ pass
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
+
140
+ def __signalHandler(self, signum, frame):
141
+ '''handle signals in a thread-safe way, frame is None unless on Windows'''
142
+ signame = signal.Signals(signum).name
143
+ handler = getattr(self, f'on_{signame.lower()}', None)
144
+ if handler:
145
+ if handler(signum, frame):
146
+ return
147
+ logging.info('Terminating %s on %s signal', _.name, signame)
148
+ self.loop.call_soon_threadsafe(self.stop)
149
+
150
+ # demonstrate a specific signal handler
151
+ # and print newline after ^C on terminals
152
+ def on_sigint(self, signum, frame):
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
+
_/auth.py ADDED
@@ -0,0 +1,163 @@
1
+ #
2
+ # (c) 2015-2023 Matthew Shaw
3
+ #
4
+ # Authors
5
+ # =======
6
+ # Matthew Shaw <mshaw.cx@gmail.com>
7
+ #
8
+
9
+ import base64
10
+ import binascii
11
+ import functools
12
+ import hashlib
13
+ import hmac
14
+ import os
15
+ import urllib.parse
16
+
17
+ import _
18
+
19
+
20
+ # based on tornado.web.authenticated
21
+ def _next(handler):
22
+ if handler.request.method in ("GET","HEAD"):
23
+ url = handler.get_login_url()
24
+ if "?" not in url:
25
+ if urllib.parse.urlsplit(url).scheme:
26
+ next_url = handler.request.full_url()
27
+ else:
28
+ next_url = handler.request.uri
29
+ url += "?" + urllib.parse.urlencode(dict(next=next_url))
30
+ handler.redirect(url)
31
+ else:
32
+ raise _.HTTPError(403)
33
+
34
+
35
+ # generic decorator to callback to a function
36
+ # return true to allow the method to be called
37
+ # return false to block the method
38
+ def filter(filter_func):
39
+ def _filter(method):
40
+ @functools.wraps(method)
41
+ def wrapper(self, *args, **kwds):
42
+ if filter_func(self):
43
+ return method(self, *args, **kwds)
44
+ _next(self)
45
+ return wrapper
46
+ return _filter
47
+
48
+
49
+ # same as above but pass the current_user session to the function
50
+ # blocks when no session is associated with the request
51
+ # return true to allow the method to be called
52
+ # return false to block the method
53
+ def filter_user(filter_func):
54
+ def _filter_user(method):
55
+ @functools.wraps(method)
56
+ def wrapper(self, *args, **kwds):
57
+ if self.current_user:
58
+ if filter_func(self.current_user):
59
+ return method(self, *args, **kwds)
60
+ _next(self)
61
+ return wrapper
62
+ return _filter_user
63
+
64
+
65
+ # inteded to be used by underscore apps to check that any user is logged in
66
+ current_user = filter_user(lambda current_user: True)
67
+
68
+ # intended to be over-ridden before components are loaded for more fine-grain
69
+ # control over access to component handlers
70
+ protected = filter_user(lambda current_user: True)
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
+
79
+
80
+ # implement old school user/name password prompt
81
+ # useful for command-line tools to pass as part of the URL
82
+ def basic(realm='Authentication'):
83
+ def basic_auth(method):
84
+ @functools.wraps(method)
85
+ async def wrapper(self, *args, **kwds):
86
+ auth = self.request.headers.get('Authorization', '')
87
+ if auth.startswith('Basic '):
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
93
+ component = _.config.get(_.name, 'basic', fallback=None)
94
+ if not component:
95
+ raise _.HTTPError(500, 'No component specified for basic auth')
96
+ try:
97
+ login = _.logins[component]
98
+ except KeyError:
99
+ raise _.HTTPError(500, 'Invalid component specified for basic auth')
100
+ success = await login.check(username, password)
101
+ if success:
102
+ return await _.wait(method(self, *args, **kwds))
103
+ self.set_status(401)
104
+ self.set_header('WWW-Authenticate', f'Basic realm="{realm}"')
105
+ self.finish()
106
+ return wrapper
107
+ return basic_auth
108
+
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
+
158
+ def simple_hash(value):
159
+ '''legacy unsalted hash, only kept to verify existing passwords'''
160
+ value = value.encode('utf-8')
161
+ for i in range(99999):
162
+ value = hashlib.sha512(value).digest()
163
+ return base64.b64encode(value).decode('ascii')
_/caches/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ #
2
+ # (c) 2015-2023 Matthew Shaw
3
+ #
4
+ # Authors
5
+ # =======
6
+ # Matthew Shaw <mshaw.cx@gmail.com>
7
+ #
8
+
9
+ import _
10
+
11
+
12
+ class Cache:
13
+ @classmethod
14
+ async def _(cls, component_name, **kwds):
15
+ # create a dynamic child class with kwds from the ini file
16
+ try:
17
+ members = dict(_.config['sessions'])
18
+ except KeyError:
19
+ members = {}
20
+
21
+ members['expires'] = int(members.get('expires', 24))
22
+ members['interval'] = int(members.get('interval', 60))
23
+ members.update(kwds)
24
+
25
+ # instantiate the derived class
26
+ self = type(cls.__name__, (cls,), _.prefix(members))()
27
+ _.caches[component_name] = self
28
+ await self.init(component_name, **kwds)
29
+
30
+ async def init(self, component_name, **kwds):
31
+ pass
32
+
33
+ async def close(self):
34
+ pass
35
+
36
+ async def cookie_secret(self):
37
+ raise NotImplementedError
38
+
39
+ def save_session(self, session):
40
+ raise NotImplementedError
41
+
42
+ def load_session(self, session_id):
43
+ raise NotImplementedError
44
+
45
+ def clear_session(self, session_id):
46
+ raise NotImplementedError
47
+
48
+
49
+ class Handler(_.handlers.Protected):
50
+ pass
_/caches/dbcache.py ADDED
@@ -0,0 +1,140 @@
1
+ #
2
+ # (c) 2015-2023 Matthew Shaw
3
+ #
4
+ # Authors
5
+ # =======
6
+ # Matthew Shaw <mshaw.cx@gmail.com>
7
+ #
8
+
9
+ import base64
10
+ import json
11
+ import logging
12
+ import os
13
+ import typing
14
+
15
+ import sqlalchemy
16
+
17
+ import _
18
+ import _.records
19
+
20
+ class DbCache(_.caches.Cache):
21
+ _config = 'config'
22
+ _key_col = 'key'
23
+ _val_col = 'value'
24
+ _key = 'cookie'
25
+
26
+ _table = 'sessions'
27
+ _session_col = 'session_id'
28
+
29
+ async def init(self, component_name, database=None, **kwds):
30
+ if not hasattr(_.application, 'is_session_expired'):
31
+ raise _.error('Application does not have is_session_expired function defined')
32
+
33
+ if database is None:
34
+ if 1 == len(_.databases):
35
+ database = list(_.databases.keys())[0]
36
+ else:
37
+ raise _.error('dbcache requires a database to be specified')
38
+ self.db = _.databases[database]
39
+
40
+ self.config_table = type(self._config, (_.records.RecordsInterface,self.db.Base,), {
41
+ '__tablename__' : self._config,
42
+ '__annotations__' : {
43
+ self._key_col : str,
44
+ self._val_col : typing.Optional[str],
45
+ },
46
+ '__primary_key__' : self._key_col,
47
+ self._key_col : sqlalchemy.orm.mapped_column(primary_key=True, init=False),
48
+ self._val_col : sqlalchemy.orm.mapped_column(init=False),
49
+ })
50
+
51
+ annotations = {
52
+ self._session_col : str,
53
+ }
54
+ # create the session table
55
+ columns = {
56
+ '__tablename__' : self._table,
57
+ '__annotations__' : annotations,
58
+ '__primary_key__' : self._session_col,
59
+ self._session_col : sqlalchemy.orm.mapped_column(primary_key=True, init=False),
60
+ }
61
+ for col,dbtype in kwds.items():
62
+ if not dbtype:
63
+ dbtype = 'str'
64
+ annotations[col] = typing.Optional[__builtins__.get(dbtype)]
65
+ columns[col] = sqlalchemy.orm.mapped_column(init=False)
66
+
67
+ self.session_table = type(self._table, (_.records.RecordsInterface, self.db.Base,), columns)
68
+
69
+ # interval comes from the [sessions] section of the ini
70
+ _.application.periodic(self._interval, self.clear_stale_sessions)
71
+
72
+ members = dict(
73
+ component = component_name,
74
+ db = self.db,
75
+ cls = self.session_table,
76
+ table = self._table,
77
+ session_id = self._session_col,
78
+ )
79
+ subclass = type('Sessions', (Sessions,), _.prefix(members))
80
+ _.application._record_handler('sessions', subclass)
81
+
82
+ async def cookie_secret(self):
83
+ # try to load exisiting cookie secret and return it
84
+ secret = await self.db.find_one(self.config_table, self._key)
85
+ if secret:
86
+ secret = secret[self._val_col]
87
+ else:
88
+ # otherwise generate and store the cookie secret
89
+ secret = base64.b64encode(os.urandom(32)).decode('ascii')
90
+ config = self.config_table()
91
+ config(**{self._key_col : self._key, self._val_col : secret})
92
+ await self.db.upsert(config)
93
+ return secret
94
+
95
+ async def save_session(self, session):
96
+ session = self.session_table._from_dict(**session)
97
+ await self.db.upsert(session)
98
+
99
+ async def load_session(self, session_id):
100
+ session = await self.db.find_one(self.session_table, session_id)
101
+ if not session:
102
+ return None
103
+ if await _.wait(_.application.is_session_expired(session, self._expires)):
104
+ return None
105
+ return session
106
+
107
+ async def clear_session(self, session_id):
108
+ session = await self.db.find_one(self.session_table, session_id)
109
+ if session:
110
+ await self.db.delete(session)
111
+
112
+ async def clear_stale_sessions(self):
113
+ for record in await self.db.find(self.session_table):
114
+ if await _.wait(_.application.is_session_expired(record, self._expires)):
115
+ logging.debug('Removing expired session: %s', record[self._session_col])
116
+ await self.db.delete(record)
117
+
118
+
119
+ class Sessions(_.handlers.Protected):
120
+ @_.auth.protected
121
+ async def get(self, session_id=None):
122
+ if session_id:
123
+ record = await self._db.find_one(self._cls, session_id)
124
+ self.write(record._as_dict())
125
+ else:
126
+ records = await self._db.find(self._cls)
127
+ data = [r._as_dict() for r in records]
128
+ self.write({'data':data})
129
+
130
+ @_.auth.protected
131
+ async def delete(self, session_id=None):
132
+ if session_id:
133
+ await self._db.delete(self._cls, session_id)
134
+
135
+ callback = getattr(_.application, f'on_{self._component}_delete', None)
136
+ if callback is None:
137
+ callback = getattr(_.application, 'on_dbcache_delete', None)
138
+ if callback:
139
+ await _.wait(callback(self._component, session_id))
140
+ self.set_status(204)