flaskteroids 0.1.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.
- flaskteroids/__init__.py +31 -0
- flaskteroids/__main__.py +5 -0
- flaskteroids/actions.py +207 -0
- flaskteroids/app.py +158 -0
- flaskteroids/cache/__init__.py +27 -0
- flaskteroids/cache/base.py +1 -0
- flaskteroids/cache/factory.py +5 -0
- flaskteroids/cache/inmemory.py +30 -0
- flaskteroids/cli/artifacts.py +55 -0
- flaskteroids/cli/db/commands.py +32 -0
- flaskteroids/cli/db/config.py +21 -0
- flaskteroids/cli/db/templates/default/env.py +81 -0
- flaskteroids/cli/flaskteroids.py +119 -0
- flaskteroids/cli/generators/authentication/generator.py +38 -0
- flaskteroids/cli/generators/cmd_parser.py +52 -0
- flaskteroids/cli/generators/commands.py +71 -0
- flaskteroids/cli/generators/controller/generator.py +54 -0
- flaskteroids/cli/generators/fields.py +7 -0
- flaskteroids/cli/generators/mailer/cmd_parser.py +25 -0
- flaskteroids/cli/generators/mailer/generator.py +50 -0
- flaskteroids/cli/generators/migrations/cmd_parser.py +198 -0
- flaskteroids/cli/generators/migrations/generator.py +25 -0
- flaskteroids/cli/generators/model/generator.py +20 -0
- flaskteroids/cli/generators/resource/cmd_parser.py +38 -0
- flaskteroids/cli/generators/resource/generator.py +24 -0
- flaskteroids/cli/generators/scaffold/cmd_parser.py +43 -0
- flaskteroids/cli/generators/scaffold/generator.py +32 -0
- flaskteroids/cli/generators/src_modifier.py +58 -0
- flaskteroids/cli/generators/templates.py +10 -0
- flaskteroids/concern.py +4 -0
- flaskteroids/controller.py +86 -0
- flaskteroids/controllers/health_controller.py +6 -0
- flaskteroids/controllers/welcome_controller.py +59 -0
- flaskteroids/csrf.py +28 -0
- flaskteroids/current.py +24 -0
- flaskteroids/db.py +17 -0
- flaskteroids/discovery.py +33 -0
- flaskteroids/exceptions.py +18 -0
- flaskteroids/extensions/db.py +81 -0
- flaskteroids/extensions/jobs.py +61 -0
- flaskteroids/extensions/mail.py +20 -0
- flaskteroids/extensions/routes.py +230 -0
- flaskteroids/extensions/utils.py +33 -0
- flaskteroids/fields.py +124 -0
- flaskteroids/flash.py +20 -0
- flaskteroids/form.py +119 -0
- flaskteroids/helpers/__init__.py +55 -0
- flaskteroids/inflector.py +136 -0
- flaskteroids/jobs/job.py +13 -0
- flaskteroids/mailer.py +132 -0
- flaskteroids/model.py +543 -0
- flaskteroids/rate_limit.py +26 -0
- flaskteroids/registry.py +13 -0
- flaskteroids/rules.py +53 -0
- flaskteroids-0.1.0.dist-info/METADATA +564 -0
- flaskteroids-0.1.0.dist-info/RECORD +60 -0
- flaskteroids-0.1.0.dist-info/WHEEL +5 -0
- flaskteroids-0.1.0.dist-info/entry_points.txt +2 -0
- flaskteroids-0.1.0.dist-info/licenses/LICENSE +21 -0
- flaskteroids-0.1.0.dist-info/top_level.txt +1 -0
flaskteroids/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from flask import redirect
|
|
2
|
+
from flaskteroids.actions import params
|
|
3
|
+
from flaskteroids.flash import flash
|
|
4
|
+
from flaskteroids.rules import rules
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = _version("flaskteroids")
|
|
10
|
+
except PackageNotFoundError:
|
|
11
|
+
# This means that package is not installed
|
|
12
|
+
__version__ = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def redirect_to(target, **kwargs):
|
|
16
|
+
notice = kwargs.pop('notice', None)
|
|
17
|
+
if notice:
|
|
18
|
+
flash['notice'] = notice
|
|
19
|
+
alert = kwargs.pop('alert', None)
|
|
20
|
+
if alert:
|
|
21
|
+
flash['alert'] = alert
|
|
22
|
+
status = kwargs.get('status') or 302
|
|
23
|
+
return redirect(target, code=status)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
'params',
|
|
28
|
+
'flash',
|
|
29
|
+
'rules',
|
|
30
|
+
'redirect_to'
|
|
31
|
+
]
|
flaskteroids/__main__.py
ADDED
flaskteroids/actions.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from werkzeug.local import LocalProxy
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from typing import Any
|
|
6
|
+
from collections import UserDict, defaultdict
|
|
7
|
+
from flask import g
|
|
8
|
+
from flaskteroids import registry
|
|
9
|
+
from flaskteroids.exceptions import InvalidParameter, MissingParameter, ProgrammerError
|
|
10
|
+
from flaskteroids.discovery import discover_methods
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register_actions(cls, base_cls):
|
|
17
|
+
ns = registry.get(cls)
|
|
18
|
+
ns['actions'] = discover_methods(
|
|
19
|
+
cls, ignore=set(discover_methods(base_cls))
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_actions(cls):
|
|
24
|
+
ns = registry.get(cls)
|
|
25
|
+
return ns.get('actions') or []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def decorate_action(cls, action_fn):
|
|
29
|
+
@wraps(action_fn)
|
|
30
|
+
def wrapper(*args, **kwargs):
|
|
31
|
+
ns = registry.get(cls)
|
|
32
|
+
before_action = [getattr(cls, ba) for ba in ns.get('before_action', {}).get(action_fn.__name__, [])]
|
|
33
|
+
for ba in before_action:
|
|
34
|
+
res = ba(*args, **kwargs)
|
|
35
|
+
if res:
|
|
36
|
+
return res
|
|
37
|
+
around_action = [
|
|
38
|
+
getattr(cls, aa)(*args, **kwargs)
|
|
39
|
+
for aa in ns.get('around_action', {}).get(action_fn.__name__, [])
|
|
40
|
+
]
|
|
41
|
+
for aa in around_action:
|
|
42
|
+
next(aa)
|
|
43
|
+
res = action_fn(*args, **kwargs)
|
|
44
|
+
for aa in reversed(around_action):
|
|
45
|
+
next(aa, None)
|
|
46
|
+
after_action = [getattr(cls, aa) for aa in ns.get('after_action', {}).get(action_fn.__name__, [])]
|
|
47
|
+
for aa in after_action:
|
|
48
|
+
aa(*args, **kwargs)
|
|
49
|
+
return res
|
|
50
|
+
return wrapper
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def before_action(method_name: str, *, only=None):
|
|
54
|
+
def bind(cls):
|
|
55
|
+
if not method_name.startswith('_'):
|
|
56
|
+
raise ProgrammerError('Before action methods should follow conventions for private methods')
|
|
57
|
+
getattr(cls, method_name)
|
|
58
|
+
actions = only if only else None
|
|
59
|
+
ns = registry.get(cls)
|
|
60
|
+
|
|
61
|
+
if 'before_action' not in ns:
|
|
62
|
+
ns['before_action'] = defaultdict(lambda: [])
|
|
63
|
+
if not actions:
|
|
64
|
+
actions = ns['actions'] if 'actions' in ns else []
|
|
65
|
+
|
|
66
|
+
for action_name in actions:
|
|
67
|
+
_logger.debug(f'before_action: setting {method_name} before {action_name} on {cls.__name__}')
|
|
68
|
+
ns['before_action'][action_name].append(method_name)
|
|
69
|
+
return bind
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def around_action(method_name: str, *, only=None):
|
|
73
|
+
def bind(cls):
|
|
74
|
+
if not method_name.startswith('_'):
|
|
75
|
+
raise ProgrammerError('Around action methods should follow conventions for private methods')
|
|
76
|
+
getattr(cls, method_name)
|
|
77
|
+
actions = only if only else None
|
|
78
|
+
ns = registry.get(cls)
|
|
79
|
+
|
|
80
|
+
if 'around_action' not in ns:
|
|
81
|
+
ns['around_action'] = defaultdict(lambda: [])
|
|
82
|
+
if not actions:
|
|
83
|
+
actions = ns['actions'] if 'actions' in ns else []
|
|
84
|
+
|
|
85
|
+
for action_name in actions:
|
|
86
|
+
_logger.debug(f'around_action: setting {method_name} around {action_name} on {cls.__name__}')
|
|
87
|
+
ns['around_action'][action_name].append(method_name)
|
|
88
|
+
return bind
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def after_action(method_name: str, *, only=None):
|
|
92
|
+
def bind(cls):
|
|
93
|
+
if not method_name.startswith('_'):
|
|
94
|
+
raise ProgrammerError('After action methods should follow conventions for private methods')
|
|
95
|
+
getattr(cls, method_name)
|
|
96
|
+
actions = only if only else None
|
|
97
|
+
ns = registry.get(cls)
|
|
98
|
+
|
|
99
|
+
if 'after_action' not in ns:
|
|
100
|
+
ns['after_action'] = defaultdict(lambda: [])
|
|
101
|
+
if not actions:
|
|
102
|
+
actions = ns['actions'] if 'actions' in ns else []
|
|
103
|
+
|
|
104
|
+
for action_name in actions:
|
|
105
|
+
_logger.debug(f'after_action: setting {method_name} after {action_name} on {cls.__name__}')
|
|
106
|
+
ns['after_action'][action_name].append(method_name)
|
|
107
|
+
return bind
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def skip_before_action(method_name: str, *, only=None):
|
|
111
|
+
def bind(cls):
|
|
112
|
+
ns = registry.get(cls)
|
|
113
|
+
actions = only if only else None
|
|
114
|
+
if not actions:
|
|
115
|
+
actions = ns['actions'] if 'actions' in ns else []
|
|
116
|
+
for action_name in actions:
|
|
117
|
+
if 'before_action' not in ns:
|
|
118
|
+
continue
|
|
119
|
+
if action_name not in ns['before_action']:
|
|
120
|
+
continue
|
|
121
|
+
if method_name not in ns['before_action'][action_name]:
|
|
122
|
+
continue
|
|
123
|
+
ns['before_action'][action_name].remove(method_name)
|
|
124
|
+
return bind
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ActionParameters(UserDict):
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def new(cls, params):
|
|
131
|
+
p = ActionParameters()
|
|
132
|
+
p.update(params)
|
|
133
|
+
return p
|
|
134
|
+
|
|
135
|
+
def expect(self, *args, **kwargs):
|
|
136
|
+
root = {}
|
|
137
|
+
for arg in args:
|
|
138
|
+
schema = self._schema('params', [arg])
|
|
139
|
+
root.update(self._expect('params', self.data, schema))
|
|
140
|
+
expected = [root] if root else []
|
|
141
|
+
for k, v in kwargs.items():
|
|
142
|
+
schema = self._schema(f'params.{k}', [(k, v)])
|
|
143
|
+
res = self._expect(f'params.{k}', self.data, schema)
|
|
144
|
+
expected.append(res[k])
|
|
145
|
+
if not expected:
|
|
146
|
+
return {}
|
|
147
|
+
elif len(expected) == 1:
|
|
148
|
+
return expected[0]
|
|
149
|
+
else:
|
|
150
|
+
return expected
|
|
151
|
+
|
|
152
|
+
def _schema(self, key, fields):
|
|
153
|
+
if not isinstance(fields, list):
|
|
154
|
+
raise ProgrammerError(key, 'Incorrect fields specification. Fields should always be lists')
|
|
155
|
+
schema = {}
|
|
156
|
+
for f in fields:
|
|
157
|
+
if isinstance(f, str):
|
|
158
|
+
schema[f] = Any
|
|
159
|
+
elif isinstance(f, tuple) and len(f) == 2:
|
|
160
|
+
fk, fv = f
|
|
161
|
+
if isinstance(fv, list) and len(fv) == 0:
|
|
162
|
+
schema[fk] = list
|
|
163
|
+
elif isinstance(fv, list) and len(fv) == 1 and isinstance(fv[0], list):
|
|
164
|
+
schema[fk] = [self._schema(fk, fv[0])]
|
|
165
|
+
else:
|
|
166
|
+
schema[fk] = self._schema(fk, fv)
|
|
167
|
+
else:
|
|
168
|
+
raise ProgrammerError(key, 'Incorrect field specification. Field must be str / tuple')
|
|
169
|
+
return schema
|
|
170
|
+
|
|
171
|
+
def _expect(self, key, value, schema):
|
|
172
|
+
if not isinstance(value, dict):
|
|
173
|
+
raise InvalidParameter(key, 'Invalid parameter type. Not a dict')
|
|
174
|
+
expected = {}
|
|
175
|
+
for k, v in schema.items():
|
|
176
|
+
if k not in value:
|
|
177
|
+
raise MissingParameter(key, k, 'Parameter is missing')
|
|
178
|
+
elif value[k] is None:
|
|
179
|
+
expected[k] = None
|
|
180
|
+
elif v is Any:
|
|
181
|
+
if type(value[k]) not in (int, float, str, bool):
|
|
182
|
+
raise InvalidParameter(key, k, 'Invalid parameter type. Not a scalar')
|
|
183
|
+
expected[k] = value[k]
|
|
184
|
+
elif v is list:
|
|
185
|
+
if not isinstance(value[k], list):
|
|
186
|
+
raise InvalidParameter(key, k, 'Invalid parameter type. Not a list')
|
|
187
|
+
if any(type(vi) not in (int, float, str, bool) for vi in value[k]):
|
|
188
|
+
raise InvalidParameter(key, k, 'Invalid parameter type. Not a scalars list')
|
|
189
|
+
expected[k] = value[k]
|
|
190
|
+
elif isinstance(v, list):
|
|
191
|
+
if not isinstance(value[k], list):
|
|
192
|
+
raise InvalidParameter(key, k, 'Invalid parameter type. Not a list')
|
|
193
|
+
expected[k] = [self._expect(f'{key}.{k}[{i}]', vi, v[0]) for i, vi in enumerate(value[k])]
|
|
194
|
+
elif isinstance(v, dict):
|
|
195
|
+
if not isinstance(value[k], dict):
|
|
196
|
+
raise InvalidParameter(key, k, 'Invalid parameter type. Not a dict')
|
|
197
|
+
expected[k] = self._expect(f'{key}.{k}', value[k], v)
|
|
198
|
+
return expected
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _get_params():
|
|
202
|
+
if 'params' not in g:
|
|
203
|
+
g.params = ActionParameters()
|
|
204
|
+
return g.params
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
params = LocalProxy(_get_params)
|
flaskteroids/app.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import secrets
|
|
3
|
+
from http import HTTPStatus
|
|
4
|
+
from werkzeug.middleware.proxy_fix import ProxyFix
|
|
5
|
+
from werkzeug.exceptions import default_exceptions
|
|
6
|
+
from flask.app import Flask
|
|
7
|
+
from flaskteroids.extensions.mail import MailExtension
|
|
8
|
+
import flaskteroids.model as model
|
|
9
|
+
from flaskteroids.db import session
|
|
10
|
+
from flaskteroids.flash import flash
|
|
11
|
+
from flaskteroids import helpers
|
|
12
|
+
from flaskteroids.csrf import CSRFToken
|
|
13
|
+
from flaskteroids.extensions.jobs import JobsExtension
|
|
14
|
+
from flaskteroids.extensions.db import SQLAlchemyExtension
|
|
15
|
+
from flaskteroids.extensions.routes import RoutesExtension
|
|
16
|
+
from flaskteroids.cli.generators import commands as generate_commands
|
|
17
|
+
from flaskteroids.cli.db import commands as db_commands
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_app(import_name, config=None):
|
|
24
|
+
config = _config(config)
|
|
25
|
+
app = Flask(import_name, template_folder=config['VIEWS']['LOCATION'])
|
|
26
|
+
app.wsgi_app = ProxyFix(app.wsgi_app)
|
|
27
|
+
|
|
28
|
+
_attach_config(app, config)
|
|
29
|
+
_register_routes(app)
|
|
30
|
+
_configure_orm(app)
|
|
31
|
+
_prepare_shell_context(app)
|
|
32
|
+
_prepare_template_contexts(app)
|
|
33
|
+
_register_error_handlers(app)
|
|
34
|
+
_register_cli_commands(app)
|
|
35
|
+
_setup_csrf(app)
|
|
36
|
+
_setup_jobs(app)
|
|
37
|
+
_setup_mailers(app)
|
|
38
|
+
|
|
39
|
+
return app
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _config(overwrites):
|
|
43
|
+
cfg = {
|
|
44
|
+
'SERVER_NAME': 'localhost:5000',
|
|
45
|
+
'APPLICATION_ROOT': '/',
|
|
46
|
+
'PREFERRED_URL_SCHEME': 'http',
|
|
47
|
+
'MODELS': {'LOCATION': 'app.models'},
|
|
48
|
+
'VIEWS': {'LOCATION': 'app/views/'},
|
|
49
|
+
'CONTROLLERS': {'LOCATION': 'app.controllers'},
|
|
50
|
+
'ROUTES': {'LOCATION': 'config.routes'},
|
|
51
|
+
'DB': {'SQLALCHEMY_URL': 'sqlite:///storage/database.db'},
|
|
52
|
+
'JOBS': {
|
|
53
|
+
'LOCATION': 'app.jobs',
|
|
54
|
+
'CELERY_BROKER_URL': 'sqla+sqlite:///storage/jobs_database.db'
|
|
55
|
+
},
|
|
56
|
+
'MAILERS': {
|
|
57
|
+
'LOCATION': 'app.mailers',
|
|
58
|
+
'SEND_MAILS': False
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if overwrites:
|
|
62
|
+
cfg.update(overwrites)
|
|
63
|
+
if not cfg.get('SECRET_KEY'):
|
|
64
|
+
cfg['SECRET_KEY'] = secrets.token_hex(64)
|
|
65
|
+
return cfg
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _attach_config(app, config):
|
|
69
|
+
if config:
|
|
70
|
+
app.config.update(config)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _register_routes(app):
|
|
74
|
+
RoutesExtension(app)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _configure_orm(app):
|
|
78
|
+
SQLAlchemyExtension(app)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _register_error_handlers(app):
|
|
82
|
+
|
|
83
|
+
def handle_error(code):
|
|
84
|
+
def _(e):
|
|
85
|
+
HttpExceptionClass = default_exceptions.get(code)
|
|
86
|
+
if HttpExceptionClass:
|
|
87
|
+
return HttpExceptionClass(description=str(e)).get_response()
|
|
88
|
+
raise e
|
|
89
|
+
return _
|
|
90
|
+
|
|
91
|
+
app.register_error_handler(model.RecordNotFoundException, handle_error(HTTPStatus.NOT_FOUND))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _register_cli_commands(app):
|
|
95
|
+
app.cli.add_command(generate_commands.generate, name='generate')
|
|
96
|
+
app.cli.add_command(generate_commands.generate, name='g')
|
|
97
|
+
app.cli.add_command(db_commands.init)
|
|
98
|
+
app.cli.add_command(db_commands.migrate)
|
|
99
|
+
app.cli.add_command(db_commands.rollback)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _prepare_shell_context(app):
|
|
103
|
+
|
|
104
|
+
@app.shell_context_processor
|
|
105
|
+
def _():
|
|
106
|
+
_logger.debug('Preparing shell context')
|
|
107
|
+
db = app.extensions['flaskteroids.db']
|
|
108
|
+
|
|
109
|
+
def commit(fn):
|
|
110
|
+
"""This commits immediately changes on models from the shell"""
|
|
111
|
+
def wrapper(*args, **kwargs):
|
|
112
|
+
res = fn(*args, **kwargs)
|
|
113
|
+
session.commit()
|
|
114
|
+
return res
|
|
115
|
+
return wrapper
|
|
116
|
+
|
|
117
|
+
for model_cls in db.models.values():
|
|
118
|
+
model_cls.create = commit(model_cls.create)
|
|
119
|
+
model_cls.update = commit(model_cls.update)
|
|
120
|
+
model_cls.save = commit(model_cls.save)
|
|
121
|
+
model_cls.destroy = commit(model_cls.destroy)
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
**db.models,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _prepare_template_contexts(app):
|
|
129
|
+
|
|
130
|
+
db = app.extensions['flaskteroids.db']
|
|
131
|
+
|
|
132
|
+
app.jinja_env.globals['render'] = helpers.render
|
|
133
|
+
app.jinja_env.globals['button_to'] = helpers.button_to
|
|
134
|
+
app.jinja_env.globals['link_to'] = helpers.link_to
|
|
135
|
+
app.jinja_env.globals['form_with'] = helpers.form_with
|
|
136
|
+
app.jinja_env.globals['csrf_token'] = CSRFToken(app.config.get('SECRET_KEY')).generate
|
|
137
|
+
|
|
138
|
+
@app.context_processor
|
|
139
|
+
def _():
|
|
140
|
+
return {
|
|
141
|
+
'flash': flash.messages,
|
|
142
|
+
**db.models
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _setup_csrf(app):
|
|
147
|
+
@app.before_request
|
|
148
|
+
def _():
|
|
149
|
+
csrft = CSRFToken(app.config.get('SECRET_KEY'))
|
|
150
|
+
csrft.validate()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _setup_jobs(app):
|
|
154
|
+
JobsExtension(app)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _setup_mailers(app):
|
|
158
|
+
MailExtension(app)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from flaskteroids.cache.factory import get_cache
|
|
2
|
+
from flaskteroids.cache.base import MISSING
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def value(key: str, ttl=None):
|
|
6
|
+
def wrapper(fn):
|
|
7
|
+
def decorator(*args, **kwargs):
|
|
8
|
+
value = fetch(key)
|
|
9
|
+
if value is not MISSING:
|
|
10
|
+
return value
|
|
11
|
+
value = fn(*args, **kwargs)
|
|
12
|
+
store(key, value, ttl)
|
|
13
|
+
return value
|
|
14
|
+
return decorator
|
|
15
|
+
return wrapper
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def store(key: str, value, ttl=None):
|
|
19
|
+
get_cache().store(key, value, ttl)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fetch(key: str):
|
|
23
|
+
return get_cache().fetch(key)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def increment(key: str, ttl=None):
|
|
27
|
+
return get_cache().increment(key, ttl)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
MISSING = object()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from flaskteroids.cache.base import MISSING
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
_cache = {}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def store(key: str, value, ttl=None):
|
|
9
|
+
now = time.time()
|
|
10
|
+
_cache[key] = (value, now + ttl if ttl else None)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def fetch(key: str):
|
|
14
|
+
now = time.time()
|
|
15
|
+
cached = _cache.get(key)
|
|
16
|
+
|
|
17
|
+
if cached:
|
|
18
|
+
value, timestamp = cached
|
|
19
|
+
if not timestamp or now <= timestamp:
|
|
20
|
+
return value
|
|
21
|
+
return MISSING
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def increment(key: str, ttl=None):
|
|
25
|
+
val = fetch(key)
|
|
26
|
+
if val is MISSING:
|
|
27
|
+
val = 0
|
|
28
|
+
val += 1
|
|
29
|
+
store(key, val, ttl)
|
|
30
|
+
return val
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import os
|
|
3
|
+
import textwrap
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ArtifactsBuilderException(Exception):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ArtifactsBuilder:
|
|
13
|
+
|
|
14
|
+
def __init__(self, base_path: str, notify_fn=None):
|
|
15
|
+
self._base_path = base_path
|
|
16
|
+
self._notify = notify_fn or (lambda txt: None)
|
|
17
|
+
|
|
18
|
+
def _join(self, name):
|
|
19
|
+
if not name:
|
|
20
|
+
return Path(self._base_path)
|
|
21
|
+
return Path(self._base_path, name)
|
|
22
|
+
|
|
23
|
+
def dir(self, name=None):
|
|
24
|
+
os.makedirs(self._join(name), exist_ok=True)
|
|
25
|
+
self._notify(f" create {name}")
|
|
26
|
+
|
|
27
|
+
def file(self, name, contents=None):
|
|
28
|
+
file_path = self._join(name)
|
|
29
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
file_path.touch(exist_ok=True)
|
|
31
|
+
if contents:
|
|
32
|
+
file_path.write_text(self._clean(contents))
|
|
33
|
+
self._notify(f" create {name}")
|
|
34
|
+
|
|
35
|
+
def modify_py_file(self, name, contents):
|
|
36
|
+
with open(self._join(name), "r") as source:
|
|
37
|
+
tree = ast.parse(source.read())
|
|
38
|
+
tree = contents().visit(tree)
|
|
39
|
+
with open(self._join(name), "w") as target:
|
|
40
|
+
target.write(ast.unparse(tree))
|
|
41
|
+
self._notify(f" modify {name}")
|
|
42
|
+
|
|
43
|
+
def run(self, cmd: str):
|
|
44
|
+
res = subprocess.run(
|
|
45
|
+
cmd.split(),
|
|
46
|
+
cwd=self._base_path,
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True
|
|
49
|
+
)
|
|
50
|
+
if res.returncode != 0:
|
|
51
|
+
raise ArtifactsBuilderException(f'command {cmd} returned {res.returncode}:\n{res.stderr}')
|
|
52
|
+
self._notify(f" run {cmd}")
|
|
53
|
+
|
|
54
|
+
def _clean(self, txt: str):
|
|
55
|
+
return textwrap.dedent(txt).lstrip()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from flask.cli import with_appcontext
|
|
3
|
+
from alembic import command
|
|
4
|
+
from flaskteroids.cli.db.config import get_config
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@click.command('db:init')
|
|
8
|
+
@with_appcontext
|
|
9
|
+
def init():
|
|
10
|
+
"""Init database migrations"""
|
|
11
|
+
config = get_config()
|
|
12
|
+
directory = config.get_main_option('script_location')
|
|
13
|
+
assert directory
|
|
14
|
+
command.init(config, directory, template='default', package=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.command('db:migrate')
|
|
18
|
+
@with_appcontext
|
|
19
|
+
def migrate():
|
|
20
|
+
"""Apply database migrations"""
|
|
21
|
+
config = get_config()
|
|
22
|
+
revision = 'head'
|
|
23
|
+
command.upgrade(config, revision=revision)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@click.command('db:rollback')
|
|
27
|
+
@with_appcontext
|
|
28
|
+
def rollback():
|
|
29
|
+
"""Revert database migrations"""
|
|
30
|
+
config = get_config()
|
|
31
|
+
revision = '-1'
|
|
32
|
+
command.downgrade(config, revision=revision)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from flask import current_app
|
|
3
|
+
from alembic.config import Config as AlembicConfig
|
|
4
|
+
|
|
5
|
+
_directory = os.path.join('db', 'migrate')
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Config(AlembicConfig):
|
|
9
|
+
|
|
10
|
+
def get_template_directory(self):
|
|
11
|
+
package_dir = os.path.abspath(os.path.dirname(__file__))
|
|
12
|
+
return os.path.join(package_dir, 'templates')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_config():
|
|
16
|
+
config = Config()
|
|
17
|
+
config.config_file_name = os.path.join(_directory, 'alembic.ini')
|
|
18
|
+
config.set_main_option('revision_environment', 'true')
|
|
19
|
+
config.set_main_option('script_location', _directory)
|
|
20
|
+
config.set_main_option('sqlalchemy.url', current_app.config['DB']['SQLALCHEMY_URL'])
|
|
21
|
+
return config
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from logging.config import fileConfig
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import engine_from_config
|
|
4
|
+
from sqlalchemy import pool
|
|
5
|
+
|
|
6
|
+
from alembic import context
|
|
7
|
+
|
|
8
|
+
# this is the Alembic Config object, which provides
|
|
9
|
+
# access to the values within the .ini file in use.
|
|
10
|
+
config = context.config
|
|
11
|
+
|
|
12
|
+
# Interpret the config file for Python logging.
|
|
13
|
+
# This line sets up loggers basically.
|
|
14
|
+
if config.config_file_name is not None:
|
|
15
|
+
fileConfig(config.config_file_name)
|
|
16
|
+
|
|
17
|
+
# add your model's MetaData object here
|
|
18
|
+
# for 'autogenerate' support
|
|
19
|
+
# from myapp import mymodel
|
|
20
|
+
# target_metadata = mymodel.Base.metadata
|
|
21
|
+
target_metadata = None
|
|
22
|
+
|
|
23
|
+
# other values from the config, defined by the needs of env.py,
|
|
24
|
+
# can be acquired:
|
|
25
|
+
# my_important_option = config.get_main_option("my_important_option")
|
|
26
|
+
# ... etc.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_migrations_offline() -> None:
|
|
30
|
+
"""Run migrations in 'offline' mode.
|
|
31
|
+
|
|
32
|
+
This configures the context with just a URL
|
|
33
|
+
and not an Engine, though an Engine is acceptable
|
|
34
|
+
here as well. By skipping the Engine creation
|
|
35
|
+
we don't even need a DBAPI to be available.
|
|
36
|
+
|
|
37
|
+
Calls to context.execute() here emit the given string to the
|
|
38
|
+
script output.
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
url = config.get_main_option("sqlalchemy.url")
|
|
42
|
+
context.configure(
|
|
43
|
+
url=url,
|
|
44
|
+
target_metadata=target_metadata,
|
|
45
|
+
literal_binds=True,
|
|
46
|
+
dialect_opts={"paramstyle": "named"},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
with context.begin_transaction():
|
|
50
|
+
context.run_migrations()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_migrations_online() -> None:
|
|
54
|
+
"""Run migrations in 'online' mode.
|
|
55
|
+
|
|
56
|
+
In this scenario we need to create an Engine
|
|
57
|
+
and associate a connection with the context.
|
|
58
|
+
|
|
59
|
+
"""
|
|
60
|
+
connectable = engine_from_config(
|
|
61
|
+
config.get_section(config.config_ini_section, {}),
|
|
62
|
+
prefix="sqlalchemy.",
|
|
63
|
+
poolclass=pool.NullPool,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
with connectable.connect() as connection:
|
|
67
|
+
context.configure(
|
|
68
|
+
connection=connection,
|
|
69
|
+
target_metadata=target_metadata,
|
|
70
|
+
transaction_per_migration=True,
|
|
71
|
+
render_as_batch=True
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
with context.begin_transaction():
|
|
75
|
+
context.run_migrations()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if context.is_offline_mode():
|
|
79
|
+
run_migrations_offline()
|
|
80
|
+
else:
|
|
81
|
+
run_migrations_online()
|