Skip to content

Commit 830f93b

Browse files
authored
Merge pull request #128 from level12/124-component-structure
introduce a component scheme
2 parents b11fe7e + e6611b8 commit 830f93b

File tree

16 files changed

+275
-28
lines changed

16 files changed

+275
-28
lines changed

keg/__init__.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
from __future__ import absolute_import
22

3-
from flask import current_app
3+
from flask import current_app # noqa: F401
44

5-
from keg.app import Keg
5+
from keg.app import Keg # noqa: F401
6+
from keg.component import ( # noqa: F401
7+
KegComponent,
8+
KegModelComponent,
9+
KegModelViewComponent,
10+
KegViewComponent,
11+
)
612

7-
# silence linter
8-
current_app
9-
Keg
13+
__all__ = [
14+
'Keg',
15+
'KegComponent',
16+
'KegModelComponent',
17+
'KegModelViewComponent',
18+
'KegViewComponent',
19+
'current_app',
20+
]

keg/app.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import absolute_import
22

3+
import importlib
34
import warnings
45

56
import flask
@@ -135,6 +136,12 @@ def init_keyring(self):
135136
def init_extensions(self):
136137
self.init_db()
137138

139+
# Keg components are essentially flask extensions, so they need to be set up during the
140+
# init process. Being extensions, it makes sense to trigger this here. But, they will best
141+
# be set up at the end of the init process, because other app-specific extensions may be
142+
# set up in `on_init_complete` for error handling/notifications and the like.
143+
signals.init_complete.connect(self.init_registered_components, sender=self)
144+
138145
def db_manager_cls(self):
139146
from keg.db import DatabaseManager
140147
return DatabaseManager
@@ -144,6 +151,16 @@ def init_db(self):
144151
cls = self.db_manager_cls()
145152
self.db_manager = cls(self)
146153

154+
def init_registered_components(self, app):
155+
# KEG_REGISTERED_COMPONENTS is presumed to be a set/list/iterable of dotted paths usable
156+
# for import. At the top level of the imported path, there should be a `__component__`
157+
# that takes the dotted path that was used for import (as an absolute parent for relative
158+
# imports) and has an init_app. Ideally, based on KegComponent.
159+
for comp_path in self.config.get('KEG_REGISTERED_COMPONENTS', set()):
160+
comp_module = importlib.import_module(comp_path)
161+
comp_object = getattr(comp_module, '__component__')
162+
comp_object.init_app(self, parent_path=comp_path)
163+
147164
def init_blueprints(self):
148165
# TODO: probably want to be selective about adding our blueprint
149166
self.register_blueprint(kegbp)

keg/component.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import importlib
2+
3+
import flask
4+
5+
6+
class KegComponent:
7+
""" Keg components follow the paradigm of flask extensions, and provide some defaults for the
8+
purpose of setting up model/view structure. Using components, a project may be broken down into
9+
logical blocks, each having their own entities, blueprints, templates, tests, etc.
10+
11+
Setup involves:
12+
- KEG_REGISTERED_COMPONENTS config setting: assumed to be an iterable of importable dotted paths
13+
- `__component__`: at the top level of each dotted path, this attribute should point to an
14+
instance of `KegComponent`. E.g. `__component__ = KegComponent('widgets')`
15+
16+
By default, components will load entities from `db_visit_modules` into metadata and register
17+
any blueprints specified by `load_blueprints`.
18+
19+
Blueprints can be created with the helper methods `create_named_blueprint` or `create_blueprint`
20+
in order to have a configured template folder relative to the blueprint path.
21+
22+
Use KegModelComponent, KegViewComponent, or KegModelViewComponent for some helpful defaults for
23+
model/blueprint discovery.
24+
25+
db_visit_modules: an iterable of dotted paths (e.g. `.mycomponent.entities`,
26+
`app.component.extraentities`) where Keg can find the entities for this
27+
component to load them into the metadata.
28+
29+
.. note:: Normally this is not explicitly required but can be useful in cases where imports
30+
won't reach that file.
31+
32+
.. note:: This can accept relative dotted paths (starts with `.`) and it will prepend the
33+
component python package determined by Keg when instantiating the component. You can also
34+
pass absolute dotted paths and no alterations will be performed.
35+
36+
load_blueprints: an iterable of tuples, each having a dotted path (e.g. `.mycomponent.views`,
37+
`app.component.extraviews`) and the blueprint attribute name to load and
38+
register on the app. E.g. `(('.views', 'component_bp'), )`
39+
40+
.. note:: This can accept relative dotted paths (starts with `.`) and it will prepend the
41+
component python package determined by Keg when instantiating the component. You can also
42+
pass absolute dotted paths and no alterations will be performed.
43+
44+
template_folder: string to be passed for template config to blueprints created via the component
45+
"""
46+
db_visit_modules = tuple()
47+
load_blueprints = tuple()
48+
template_folder = 'templates'
49+
50+
def __init__(self, name, app=None, db_visit_modules=None, load_blueprints=None,
51+
template_folder=None, parent_path=None):
52+
self.name = name
53+
# Allow customization of the defaults in the constructor
54+
self.db_visit_modules = db_visit_modules or self.db_visit_modules
55+
self.load_blueprints = load_blueprints or self.load_blueprints
56+
self.template_folder = template_folder or self.template_folder
57+
if app:
58+
# Not really intended to be used this way, but it fits the flask extension paradigm
59+
# and could conceivably be set up in lieu of KEG_REGISTERED_COMPONENTS
60+
self.init_app(app, parent_path=parent_path)
61+
62+
def init_app(self, app, parent_path=None):
63+
# parent_path gets used as an absolute parent for the relative import paths of
64+
# model/blueprints.
65+
# E.g. if relative_dotted_path is `my_app.components.widget` and one of the relative import
66+
# paths is `.model.entities`, the full import is `my_app.components.widget.model.entities`
67+
self.init_config(app)
68+
self.init_db(parent_path)
69+
self.init_blueprints(app, parent_path)
70+
71+
def init_config(self, app):
72+
# Components may define their own config defaults
73+
pass
74+
75+
def init_db(self, parent_path):
76+
# Intent is to import the listed modules, so their entities are registered in metadata
77+
for dotted_path in self.db_visit_modules:
78+
import_name = dotted_path
79+
if import_name.startswith('.'):
80+
import_name = f'{parent_path}{dotted_path}'
81+
importlib.import_module(import_name)
82+
83+
def init_blueprints(self, app, parent_path):
84+
# Register any blueprints that are listed by path in load_blueprints
85+
for bp_path, bp_attr in self.load_blueprints:
86+
import_name = bp_path
87+
if import_name.startswith('.'):
88+
import_name = f'{parent_path}{bp_path}'
89+
mod_imported = importlib.import_module(import_name)
90+
app.register_blueprint(getattr(mod_imported, bp_attr))
91+
92+
def create_blueprint(self, *args, **kwargs):
93+
# Make a flask blueprint having a template folder configured
94+
kwargs['template_folder'] = kwargs.get('template_folder', self.template_folder)
95+
bp = flask.Blueprint(*args, **kwargs)
96+
97+
return bp
98+
99+
def create_named_blueprint(self, *args, **kwargs):
100+
# Make a flask blueprint named with the component name, having a template folder configured
101+
return self.create_blueprint(self.name, *args, **kwargs)
102+
103+
104+
class ModelMixin:
105+
db_visit_modules = ('.model.entities', )
106+
107+
108+
class ViewMixin:
109+
load_blueprints = (('.views', 'component_bp'), )
110+
111+
112+
class KegModelComponent(ModelMixin, KegComponent):
113+
pass
114+
115+
116+
class KegViewComponent(ViewMixin, KegComponent):
117+
pass
118+
119+
120+
class KegModelViewComponent(ModelMixin, ViewMixin, KegComponent):
121+
pass

keg/tests/test_db.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
from keg.signals import db_init_pre, db_init_post, db_clear_post, db_clear_pre
88
from keg.testing import invoke_command
99

10+
# Note: we do not want to import Blog directly here. Doing so will affect metadata collection and
11+
# prevent the tests below from proving that component initialization is loading the model.
12+
# from keg_apps.db.blog.model.entities import Blog
1013
import keg_apps.db.model.entities as ents
1114
from keg_apps.db.app import DBApp
1215
from keg_apps.db2 import DB2App
@@ -27,11 +30,14 @@ def setup_class(cls):
2730
DBApp.testing_prep()
2831

2932
def test_primary_db_entity(self):
30-
assert ents.Blog.query.count() == 0
31-
blog = ents.Blog(title=u'foo')
33+
# importing Blog here to ensure we're importing after app init, which will only succeed if
34+
# the component has loaded properly
35+
from keg_apps.db.blog.model.entities import Blog
36+
assert Blog.query.count() == 0
37+
blog = Blog(title=u'foo')
3238
db.session.add(blog)
3339
db.session.commit()
34-
assert ents.Blog.query.count() == 1
40+
assert Blog.query.count() == 1
3541

3642
def test_postgres_bind_db_entity(self):
3743
assert ents.PGDud.query.count() == 0
@@ -63,6 +69,10 @@ def setup_class(cls):
6369
DBApp.testing_prep()
6470

6571
def test_db_init_with_clear(self):
72+
# importing Blog here to ensure we're importing after app init, which will only succeed if
73+
# the component has loaded properly
74+
from keg_apps.db.blog.model.entities import Blog
75+
6676
# have to use self here to enable the inner functions to adjust an outer-context variable
6777
self.init_pre_connected = False
6878
self.init_post_connected = False
@@ -85,8 +95,8 @@ def catch_clear_pre(app):
8595
def catch_clear_post(app):
8696
self.clear_post_connected = True
8797

88-
ents.Blog.testing_create()
89-
assert ents.Blog.query.count() >= 1
98+
Blog.testing_create()
99+
assert Blog.query.count() >= 1
90100
ents.PGDud2.testing_create()
91101
assert ents.PGDud2.query.count() >= 1
92102

@@ -95,9 +105,9 @@ def catch_clear_post(app):
95105
# todo: We could check all the intermediate steps, but this is more a functional test
96106
# for now. If the record is gone and we can create an new blog post, then we assume the
97107
# clear and init went ok.
98-
assert ents.Blog.query.count() == 0
99-
ents.Blog.testing_create()
100-
assert ents.Blog.query.count() == 1
108+
assert Blog.query.count() == 0
109+
Blog.testing_create()
110+
assert Blog.query.count() == 1
101111

102112
assert self.init_pre_connected
103113
assert self.init_post_connected

keg/tests/test_web.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,11 @@ def test_blueprint_url_prefix(self):
3030
def test_blueprint_template_folder(self):
3131
resp = self.testapp.get('/tanagra/blueprint-test')
3232
assert 'blueprint template found ok' in resp
33+
34+
35+
class TestComponentBlueprint(WebBase):
36+
appcls = WebApp
37+
38+
def test_component_blueprint_loads(self):
39+
resp = self.testapp.get('/blog')
40+
assert 'I am a blog' in resp

keg_apps/db/blog/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from keg import KegModelComponent
2+
3+
4+
__component__ = KegModelComponent('blog')

keg_apps/db/blog/model/__init__.py

Whitespace-only changes.

keg_apps/db/blog/model/entities.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from blazeutils.strings import randchars
2+
3+
from keg.db import db
4+
5+
6+
class Blog(db.Model):
7+
__tablename__ = 'blogs'
8+
9+
id = db.Column(db.Integer, primary_key=True)
10+
title = db.Column(db.Unicode(50), unique=True, nullable=False)
11+
12+
@classmethod
13+
def testing_create(cls):
14+
blog = Blog(title=randchars())
15+
db.session.add(blog)
16+
db.session.commit()
17+
return blog

keg_apps/db/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
class DefaultProfile(object):
33
KEG_KEYRING_ENABLE = False
44

5+
KEG_REGISTERED_COMPONENTS = {
6+
'keg_apps.db.blog',
7+
}
8+
59

610
class TestProfile(object):
711
KEG_DB_DIALECT_OPTIONS = {

keg_apps/db/model/entities.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,6 @@
33
from keg.db import db
44

55

6-
class Blog(db.Model):
7-
__tablename__ = 'blogs'
8-
9-
id = db.Column(db.Integer, primary_key=True)
10-
title = db.Column(db.Unicode(50), unique=True, nullable=False)
11-
12-
@classmethod
13-
def testing_create(cls):
14-
blog = Blog(title=randchars())
15-
db.session.add(blog)
16-
db.session.commit()
17-
return blog
18-
19-
206
####
217
# The next several entities exist to facilitate testing of the way Keg handles multiple database
228
# binds and dialects.

0 commit comments

Comments
 (0)